File modules
RFC 0002 implements isolated file modules and a stage 2 compile-time library slice in the TypeScript engine. A file's definitions are private. Source loading registers files; it does not publish their bindings into a host session.
;; identity.forma
(export identity)
(define identity [value] value)
;; names.forma
(export Name label)
(type Name (Brand String))
(: label (-> Name String))
(define label [name] (str "Hello, " name))
;; main.forma
(import "./identity.forma" [identity])
(import "./names.forma" :as names)
(export greeting)
(define greeting (names/label (names/Name (identity "Ada"))))
greetingNamed imports expose only the selected exports. Namespace aliases use /; names/Name and names/label retain their original declaration identities. Each imported polymorphic function is instantiated independently at each call. A barrel preserves those identities:
;; public.forma
(export-from "./names.forma" [Name label])Exporting a tagged type makes its constructors available through that type:
;; status.forma
(export Status)
(type Status (Tagged (Ready {:message String}) Empty))
;; reader.forma
(import "./status.forma" [Status])
(Status.Ready {:message "Ready"})
;; Ready is not a bare imported name.Classes and errors retain nominal identity; brands and tagged types from two files remain distinct even when their names and representations match. All stage-1 type exports are transparent. Public signatures must export the owning nominal types and services they mention. Constructor-only and opaque exports are not implemented.
The implicit module core consists of intrinsic syntax, evaluation primitives, primitive/container types, and the current Effect-value authoring forms. type, class, error, service, layer, and do! remain built in. Kernel sugar can be imported from preludes/kernel.forma; it is not automatically loaded into file modules. Explicitly configured host variables remain available. Source-loading and legacy descriptor-prelude APIs do not add library bindings to module scopes. Typeclass instance imports and coherence remain deferred.
Compile-time libraries
Forms and macros use the same imports and visibility rules as values and types. A form retains its defining module's private projection helpers, patterns, hole types, checks, contracts, and editor metadata. Macro helper references retain definition-site identity; caller syntax retains caller bindings. The implemented hygiene covers generated fn and let binders; other binding constructs still need further work.
;; stripe.forma
(export price)
(type PriceDecl Symbol)
(type PriceIR {:name Symbol :label String :amount Int})
(define display-label [label] (str "Stripe: " label))
(form (price name label amount)
:types {:name (Declares PriceDecl) :label String :amount Int}
:ir PriceIR
{:name name :label (display-label label) :amount amount})
;; billing.forma
(import "./stripe.forma" [price])
(export Monthly Annual prices)
(price Monthly "Monthly" 1200)
(price Annual "Annual" 12000)
(define prices [Monthly Annual])Declares contracts introduce declaration exports. Each declaration has its invoking module's identity and a classification, declaring-form identity, and source span. During pure elaboration it is a descriptor with :identity, :classification, and :data. Private helpers retain the form library's scope. The prices export is inspectable pure data for another form's typed data hole. Exported types carry public schema syntax. Declaration and data interfaces carry compile-time schemes separately from runtime schemes; classification does not manufacture a runtime constructor.
The complete Stripe → Salesforce fixture uses billing/prices to derive picklist entries and checks imported references at their authored spans. Data provenance contains paths into the elaborated value and source declaration identities and spans. It conservatively includes all contributing input declarations on every derived member. Selection and transformation do not yet track minimal per-member input sets.
A host selects each project's automatic imports through configureSession:
await host.configureSession({
sessionId,
projects: [{
id: "billing",
base: "billing/project",
prelude: "./prelude.forma",
modules: ["billing/main.forma", "billing/prelude.forma"],
}],
});base is the resolver's importing-file base: the example resolves the setting to billing/prelude.forma. A project's source modules receive the selected module's exports under ordinary named-import collision rules. The prelude and its explicit dependency closure bootstrap without that project's automatic imports. Each module uses its owning project's selection, so two projects can use distinct form libraries in the same graph. No setting means no automatic library imports.
Pure projections can retain RuntimeExpr syntax describing a future update!; they do not invoke it. Executable Effects still require the existing explicit runner. Graph requests rebuild compile-time interfaces from current sources, including private helper dependencies.
Host and browser use
TsLanguageHost supports source-bundle, moduleGraph, typecheck, expand, evaluateInSession, and linkEffectModules workflows. The host and the @formalang/ts/modules entry point work in browsers without filesystem APIs:
import { TsLanguageHost } from "@formalang/host/ts-host";
const host = new TsLanguageHost();
const { sessionId } = await host.openSession();
await host.loadSourceBundle({
sessionId,
sources: [
{ kind: "source", sourceId: "identity.forma",
source: "(export identity) (define identity [value] value)" },
{ kind: "source", sourceId: "main.forma",
source: '(import "./identity.forma" [identity]) (identity 42)' },
],
});
const checked = await host.moduleGraph({ sessionId, sourceId: "main.forma" });
const evaluated = await host.evaluateInSession({ sessionId, sourceId: "main.forma" });
await host.closeSession({ sessionId });Loading order does not affect resolution. The host supplies a synchronous resolver (specifier, importerId) => { id, source } | undefined. In-memory bundles resolve normalized relative paths. The Node adapter in @formalang/ts/node and the forma CLI use canonical filesystem paths. Cycles report the complete import chain; missing files, private exports, collisions, and namespace mistakes report the importing author's span.
Kernel module evaluation initializes pure definitions once per runtime instance and dependency version. Entry expressions run on explicit evaluation; importing that file does not run those expressions. Updating a file creates new instances for it and its dependents. Observation requests create a fresh entry instance so that the complete execution can be observed. Effect programs execute through the linked Effect target; kernel evaluation reports module/effect-runtime instead of executing an Effect initializer.
Linked Effect TypeScript
The runnable five-file example is in conformance/modules/. It imports a polymorphic function, branded IDs, a class, tagged constructors, and an Effect service. Its main export is an Effect value. Importing the generated files prepares that value; application code supplies the service and runs the Effect.
After building the TypeScript packages:
pnpm --filter @formalang/ts build
pnpm --filter @formalang/host build
node scripts/link-forma-modules.mjs conformance/modules/main.forma .context/linked
# The host package installs the forma executable; from a checkout:
node packages/host/dist/cli.mjs file interface conformance/modules/main.forma
node packages/host/dist/cli.mjs file typecheck conformance/modules/main.formaThe TypeScript forma executable also accepts a JSON request argument or stdin:
forma request '{"op":"typecheck","sourceId":"example.forma","source":"(+ 1 2)"}'
printf '%s\n' '{"op":"parseSummary","source":"1 2"}' '{"op":"version"}' | forma daemon
forma file evaluate path/to/main.forma
forma file declarations path/to/main.formadaemon reads one JSON object per line and writes one response per line. Sessions persist across requests, which run in input order. Every response has ok and diagnostics, and successful operations expose their host projection in value. Typecheck also exposes its display string in type. Malformed requests use abi/* diagnostics, author errors keep their compiler codes and source spans, and unexpected engine failures use internal/error. Request failures do not terminate the daemon. Filesystem failures use io/error.
The exported @formalang/host/json-abi entry provides JsonRequest, JsonResponse, TypeScheme, and JsonValueProjection Effect schemas and a JsonAbi dispatcher. Its operations cover parsing, expansion, typechecking, evaluation, modules, session configuration, loading, retained values, and suspended host calls. It also dispatches emit, emitMany, emitBackends, artifactSummary, and stateful typed replSubmit. Artifact requests use sessionId, sourceId or sourceIds, and optional backend (only canonical-ir is implemented). emit also accepts inline source, with optional sourceId (default request), against the loaded preludes without storing it. With no selection, the host emits all loaded sources. sourceId takes precedence over sourceIds. replSubmit requires sessionId and source, accepts the session evaluation options, and retains bindings and types only on success. Its completed envelope includes the display string in type.
Responses carry the host projections inside value: REPL results have status and result (or a suspended host call). emitMany returns per-source artifacts arrays. Artifacts use the validated language-ts-artifact/v1 package and SHA-256 canonical JSON hashes. Summary failures return diagnostics rather than aggregate counts across failing sources. Artifact cache counters are not implemented. Failure diagnostics also appear once at the envelope level; emitMany preserves successful per-source results when another source fails.
The remaining optional LanguageHost methods are editor APIs: analyzeEditor, identifySyntax, symbolIndex, findReferences, applyEditScript, describeNodes, editScriptSchema, sourceToOutline, outlineToSource, and formSlots. They remain available through the in-process host, which carries syntax identities and editor projections; this process ABI covers compiler and session operations. Elaboration is not a separate ABI operation. The standalone elaborateProgram and elaborateSources APIs in @formalang/ts/descriptor return declaration projections; use session emit/emitMany for validated, packaged artifacts. Host schemes reject unknown names and malformed structure, including nested schemes. Int and Float are distinct host types; Number and Num are rejected in source annotations and host schemes. Tagged values preserve numeric kinds, with explicit Float payloads "NaN", "Infinity", "-Infinity", and "-0" for values JSON numbers cannot round-trip. Plain JSON erases numeric kinds and maps nonfinite Floats to null. Invalid configuration names its JSON path and locates the affected symbol, or uses a zero-width source span when no symbol occurs.
The debug operations lowerCore, typecheckCore, and typecheckCoreTyped return TypeScript core trees and inferred display types; typed trees add inferredType to expression nodes. parseSummary reports the top-level form count. incrementalSummary reports per-form spans and a stable FNV-1a 64-bit digest of UTF-8 AST shape JSON, ignoring spans, comments, and whitespace. These are structural change hints, not persistent artifact identities.
loadSource and loadSourceBundle accept timings: true. Load results include millisecond durations for the phases executed: parseMs and storeMs, plus typecheckMs, evalMs, and metacheckMs for preludes. The timing contract also reserves elaborateMs; absent phases were not run. Failed loads include timings for phases completed before the diagnostic and preserve the session snapshot.
linkEffectModules returns one .ts file per module, the generated entry filename, portable interfaces, declarations, and diagnostics. Files contain real relative ES imports and exports. Each schema, brand, service, class, and tagged constructor is emitted in its owning file. Generated filenames and identifiers encode the host's module ID; use the returned interfaces and generatedBindingName to locate an authored export. The integration fixture compiles this output with tsc and runs it with Effect, asserting zero calls at import and one call on execution.
The target retains its existing supported Effect subset. Inferred closed, polymorphic pure function signatures are supported. Generic schemas, inferred open-row signatures, and variadic functions require further target work and report diagnostics. The existing domain artifact APIs still resolve symbolic declaration data and global seed IDs in their artifact reference index. That index supplies artifact hooks with data; it never supplies functions or lexical bindings to a file module. File module interfaces now expose compile-time libraries and checked declaration data. Migrating these legacy artifact emit pipelines to those interfaces remains follow-up work. Manifests, registries, lockfiles, package imports, project commands, and RFC 0003 direct-style effects remain proposals.