Effect reference
This page is the reference for writing Effect programs in Forma. For why you might, and what it costs, see Forma for Effect.
Forma can author Effect TypeScript programs. You write schemas, errors, services, operations, and layers in Forma; the compiler checks them and generates an Effect 4 module that reads like hand-written Effect code: Schema constants, Schema.TaggedError classes, Context.Service classes, Effect.gen functions, and Layer values.
import { Mechanics } from "@formalang/ts";
const result = Mechanics.generateEffectProgram(source, { sourceId: "users.lisp" });
if (!result.ok) {
for (const d of result.diagnostics) {
console.error(`${d.span?.startLine}:${d.span?.startColumn} ${d.code} ${d.message}`);
}
} else {
writeFileSync("users.ts", result.code!);
}elaborateEffectProgram runs the same pipeline without generating code. The pipeline reads the source, projects mechanics declarations (the portable IR), checks them, and generates TypeScript only when the check finds no errors. Every diagnostic has a line and column in your source.
The Effect TypeScript conformance suite contains complete programs for each feature. For every one it records the exact generated module, typechecks it, and runs it.
For multiple files, use the isolated file-module workflow. It checks each file separately and emits real relative imports and explicit exports. Package resolution and compile-time library imports remain proposals.
Declarations
| Forma | Effect TypeScript |
|---|---|
(type User {:id UserId :nick (Option String)}) | Schema.Struct({ id: UserId, nick: Schema.optionalKey(Schema.String) }) and export type User = typeof User.Type |
(type UserId (Brand String)) | Schema.String.pipe(Schema.brand("UserId")); construct with (UserId "u-1") |
(type Role (Union :admin :member)) | Schema.Literals(["admin", "member"]) |
(Tuple A B), (Union A B), (List T), (Map String T) | tuple, union, array, and dictionary schemas |
(type Shape (Tagged :tag kind (Circle {:radius Float}) (Square {:side Float}))) | a union of records with a literal kind field |
(class Customer {:name String}) | class Customer extends Schema.Class<Customer>("Customer")({...}); construct with (Customer {:name "Ada"}) |
(error NotFound {:id String}) | class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {...}) |
(service Users (: find (-> String (Effect (Option User))))) | Context.Service with a typed find method |
(: f (-> String String)) (define f [name] (str "Hello " name)) | an ordinary typed function |
(: rates (Map String Int)) (define rates {"standard" 10}) | a typed dictionary constant |
(: find-user (-> String (Effect User [NotFound] [Users.find]))) (define find-user [id] ...) | an Effect.gen function |
(layer UsersLive :provides Users (define find [id] ...)) | a layer implementing service methods |
(layer AppLive (layer-provide A (layer-merge B C))) | composition of layers |
Signatures may appear anywhere in their module. An Effect type names its success value and optional finite sets of errors and requirements: (Effect User), (Effect User [NotFound]), or (Effect User [NotFound] [Users.find]). Requirements name individual capabilities or whole services. Scope is the requirement introduced by resources.
A zero-argument effect is a value: (: ready (Effect Bool)) (define ready (succeed true)). Refer to it as ready. Layer signatures use (Layer [Provides...] [Errors...] [Requirements...]).
Records require fields by default. (Option T) fields may be absent and generate optional wire keys. Class and error types are nominal. Refer to other schemas by their declared names. In expressions, (Ref T) is an Effect reference, and (Option T), (Result A E), (Fiber A [E]), (Stream A [E] [R]), and (-> A B) are the corresponding value types.
Effect bodies
| Forma | Effect TypeScript |
|---|---|
(do! [x eff _ eff2] body) | const x = yield* eff; yield* eff2; ... |
(do! [x eff :let [y value]] body) | mixes effects with lexical pure bindings |
(let [x value] body) | const x = value (let binds values; running an effect needs do!) |
(succeed v), a plain value | return v |
(fail (NotFound {:id id})) | yield* Effect.fail(new NotFound({ id })) |
(Users.find id), (other-op x) | users.find(id), otherOp(x) |
(if c a b), (when c ...), (unless c ...), (cond c a ... :else z) | if statements; conditions must be Bool |
(match opt (Some x) a None b) | Option.isSome with the payload bound |
(match res (Ok v) a (Err e) b) | Result.isSuccess |
(match shape (Shape.Circle c) a (Shape.Square s) b) | switch (shape.kind) with narrowing |
(match code 200 a 404 b _ c), (match flag true a false b) | switch on the value; strings and numbers need a final _ |
(match error (NotFound e) a (Forbidden f) b) | switch (error._tag) over a union of tagged errors |
(catch eff (NotFound e) handler) | Effect.catchTag |
(catch eff (A a) h1 (B b) h2) | Effect.catchTags |
(catch eff error handler) | Effect.catch |
(map-error eff f), (or-else-succeed eff v), (or-die eff) | Effect.mapError, Effect.orElseSucceed, Effect.orDie |
(option eff), (result eff) | Effect.option, Effect.result |
(acquire-release acquire (fn [r] release)), (scoped eff) | Effect.acquireRelease, Effect.scoped |
(ensuring eff finalizer), (add-finalizer eff) | Effect.ensuring, Effect.addFinalizer |
(all {:a e1 :b e2} :concurrency :unbounded), (all [e1 e2]) | Effect.all over a record or tuple |
(for-each xs (fn [x] eff) :concurrency 4) | Effect.forEach |
(race a b), (fork eff), (join fiber), (interrupt fiber) | Effect.race, Effect.forkChild, Fiber.join, Fiber.interrupt |
(sleep 10), (timeout eff 50) | Effect.sleep, Effect.timeout (fails with TimeoutError) |
(retry eff :times 3 :schedule (exponential 10)), (repeat eff :schedule (spaced 100)) | Effect.retry, Effect.repeat with Schedule.exponential, spaced, fixed, recurs, jittered |
(ref-make v), (ref-get r), (ref-set r v), (ref-update r f) | Ref.make, Ref.get, Ref.set, Ref.update |
(config Int "PORT" :default 8080) | Config.withDefault(Config.Int("PORT"), 8080) (fails with ConfigError) |
(decode User input) | Schema.decodeUnknownEffect(User)(input) (fails with SchemaError) |
(provide eff AppLive) | Effect.provide. Services used inside are resolved inside the provided effect. |
(log "message" value) | Effect.log |
Streams are values: stream-of, stream-range, stream-map, stream-filter, stream-take, and stream-map-effect build them, and stream-run-collect, stream-run-fold, and stream-run-for-each run them.
Values use ordinary Forma expressions:
- records
{:id id}and vectors; getandassocfor fields,Mapkeys, and classes;str,fn,if,cond,let,match, andSome/None;- arithmetic:
+ - * / quot mod max min abs round floor; - comparisons,
and/or/not, and=/!=; - collections:
map filter reduce find any? every? count empty? concat conj first sum; - maps:
keys vals dissoc has-key?; - strings:
join split trim upcase downcase starts-with? ends-with? includes? to-string; - options:
get-or-else is-some is-none; - durations:
millis seconds minutes.
Getting an (Option T) field or a Map key produces (Option T). Map functions use Effect's Record module, which only sees own keys. A builtin with one signature can be passed as a function, as in (map upcase names).
Literal types
As in TypeScript, a record or array literal with no target type widens its literals: {:role "member"} has :role String. To build a value of a schema with enum or tag fields, construct it, as in (Member {:name n :role "member"}), or pass it where the schema is expected. When a value that Forma types against an enum or tag ends up somewhere TypeScript has no contextual type (a generator return, Effect.succeed, a forEach body, or a const), the generator keeps the literal type: records of a schema get {...} satisfies Member and other literals get as const.
Checking
The checker runs over the portable IR, so it checks what the generator consumes. It rejects a program when:
- a value does not match its schema (missing or unknown record fields, wrong field types, or a fractional value where an
Intis required); - an operation can fail with an error, or uses a requirement, that its signature does not declare (the diagnostic points at the call that introduced it);
- a
matchmisses a case, names a case that does not exist, or has an unreachable arm; - a
catchhandles an error the effect cannot raise; - a release action or finalizer can fail;
- a layer does not implement every method of its service, a method can fail with an error the service does not declare, or a layer's declared type does not match what it provides and requires;
- a service method declares a requirement (Effect service methods are requirement-free; give the layer the dependency instead);
- names, types, errors, or requirements are unknown, or definitions are duplicated, or schemas are recursive (
Schema.suspendis not generated yet); - two Forma names become the same TypeScript name (
foo-barandfooBar), or a declaration takes a name the module needs (Effect,Math); - a value cannot be represented (
1e400, integers beyond ±2^53,__proto__keys, duplicate keys,Unitstored in a collection, an error field named_tag); - a function or constant calls a service;
- a top-level form is not part of an Effect program (an untyped
define, a misspelled declaration head, a bare expression, or an orphan signature).
TypeScript would also reject most of these mistakes once the code is generated. Forma reports them first, against the Forma source. Some checks are stricter than TypeScript:
- conditions must be
Bool, with no JavaScript truthiness; =andincludes?compare only primitives, enums, and brands, because records would be compared by reference;strandto-stringaccept only primitives, not records;Intcan flow intoFloat(NumberandNumare rejected type names), so(/ a b)cannot flow into anInt(usequot);letcannot silently run an effect.
Whole-number literals such as 0 are Int; 0.0 is Float. Use 0.0 to start a Float accumulator. Int results are checked against the safe integer range.
Each rejection case in the conformance suite records whether TypeScript would also reject the code generated with the checker bypassed.
What is not covered yet
- Recursive schemas (
Schema.suspend),Schema.TaggedClass, and schema transformations. - Generic (type-parameterised) operations and functions.
- Schedule composition beyond
jittered(both,either,while/untilpredicates),Queue,PubSub,Deferred,Semaphore, and scoped forks. - JavaScript interop such as
Effect.promise. Host code supplies that through services. - The hosted mechanics runtime (
makeMechanicsRuntime) still executes only the original body forms. - The general HM typechecker (
Type.inferSourceStr), which drives the language server, does not yet understand layers, combinators, streams, or multi-clausecatch. For Effect programs, use the mechanics checker.