Layers that wire themselves
A layer captures the services its methods call and becomes Layer.succeed or Layer.effect, with its Layer<Out, E, In> type computed and checked.
Forma for Effect 4 · pre-alpha
If your backend is Effect from end to end, most of your code is schemas, tagged errors, services, operations, and layers. Forma makes those the language. You write them in a small typed Lisp. The compiler checks every failure and every dependency against the signature, then generates idiomatic Effect TypeScript that you could have written by hand.
(: pay (-> OrderId (Effect Order [OrderNotFound PaymentDeclined] [Orders Payments.charge]))) │ │ read → project → check → generate ▼ Effect.Effect<Order, OrderNotFound | PaymentDeclined, Orders | Payments>
↓ the real Effect checker, running in this page · try it
These panes run the real Effect compiler in your browser. Edit the operation, inspect the body's inferred failures and requirements, and open Effect TypeScript to see fresh generated code. Watch typing introduces an undeclared failure and repairs it.
00 The problem
Effect gives TypeScript typed errors, dependency injection, resources, and structured concurrency. TypeScript wasn't designed for any of them, so you write the encoding. When something is wrong, the error describes the encoding (a forty-line conditional type) instead of your decision. Forma treats those constructs as the vocabulary, so the compiler can check them and talk about them in your terms.
Effect.gen(function* () { yield* … })← sequencingdo!class Payments extends Context.Service<Payments, …>()("Payments")← a serviceserviceclass PaymentDeclined extends Schema.TaggedError<…>()(…)← a failureerrorconst Status = Schema.Literals([…]); type Status = …← a type, twicetypeLayer.effect(Orders, Effect.gen(function* () { … }))← wiringlayer{ ...order, status: "paid" } satisfies Order← an updateassocContracts
The signature is the review.
A refactor adds one yield* Payments deep inside a helper. R widens by inference, the PR is green, and nobody reviewing the diff sees that checkout can now charge a card.
Every operation declares (Effect A [Errors] [Requirements]), and the checker holds the body to it:
Payments.charge, which is finer than Effect's service-level R.A Forma program has almost no ambient I/O. Apart from logging, sleeping, and reading Config, it cannot fetch, read files, or call a library unless the call goes through a declared service, so the signature lists what the code can touch.
(type OrderId (Brand String))
(type Status (Union :pending :paid :shipped))
(type Order {:id OrderId
:status Status
:total-cents Int})
(error OrderNotFound {:id OrderId})
(error PaymentDeclined {:reason String})
(service Orders
(: find (-> OrderId (Effect (Option Order) [] [])))
(: save (-> Order (Effect Unit [] []))))
(service Payments
(: charge (-> Int (Effect String [PaymentDeclined] []))))
(: pay (-> OrderId (Effect Order [OrderNotFound PaymentDeclined] [Orders Payments.charge])))
(define pay [id]
(do! [found (Orders.find id)]
(match found
None (fail (OrderNotFound {:id id}))
(Some order)
(do! [_ (retry (Payments.charge (get order :total-cents)) :times 2)
paid (succeed (assoc order :status "paid"))
_ (Orders.save paid)]
(succeed paid)))))Diagnostics
Errors at the line you wrote, in the words you used.
Type 'OrderNotFound | PaymentDeclined' is not assignable to type 'OrderNotFound', reported on the whole generated function, three assignability frames deep.
Drop PaymentDeclined from the signature. Forma reports the mistake at the call that can raise it. TypeScript would also reject the generated code, but it reports the mismatch on the generated function, as an assignability chain. Click the problem to reveal the call that introduces it, then add the error back or press Reset, and generated TypeScript becomes available again.
Stricter where it matters
Some bugs TypeScript lets through.
An order total of 0 skips the receipt because if (total) is false. tsc accepts it, and so does every reviewer who has read a thousand lines like it.
Forma rejects these four, and tsc accepts all of them: records compared by reference, an object interpolated into a string, a fraction stored in an Int, and JavaScript truthiness in a condition. Forma also requires exhaustive match, rejects catches for errors that can't happen and finalizers that can fail, and stops a constant from reading another before it is initialized. TypeScript catches some of these in the generated code, but not all of them, and never at the Forma line.
strict.lisp
(type Money {:amount Int
:currency String})
(: same-price? (-> Money Money Bool))
(define same-price? [a b] (= a b))
(: label (-> Money String))
(define label [price] (str "price: " price))
(: half (-> Int Int))
(define half [n] (/ n 2))
(: describe (-> Int String))
(define describe [count] (if count "some" "none"))Forma
strict.lisp:5:31 error mechanics/equality
= compares strings, numbers, booleans, and enums; Money would be compared by reference in TypeScript.
strict.lisp:8:39 error mechanics/str
str joins strings, numbers, and booleans, not Money.
strict.lisp:11:19 error mechanics/type-mismatch
Expected Int, but this is Float.
strict.lisp:14:31 error mechanics/condition
Conditions must be Bool, but this is Int.tsc on the generated code
$ tsc --noEmit strict.ts
(no errors)04 Output
The single-file generator imports effect; the file-module linker also emits real imports between generated files. It contains Schema constants, Schema.TaggedError and Schema.Class classes, Context.Service classes, Effect.gen functions, and Layer values that capture their dependencies. It has no any, no casts other than as const, and no non-null assertions. You review it, check it in, and run it like any other Effect code. If you stop using Forma, you keep the TypeScript.
A layer captures the services its methods call and becomes Layer.succeed or Layer.effect, with its Layer<Out, E, In> type computed and checked.
acquire-release, scoped, all and for-each with concurrency, race, fork, timeout, and retry with schedules map to the Effect functions of the same name.
Brands, enums, tagged unions, optional fields, classes, and decode for untrusted JSON. The runtime schema and the static type come from one declaration.
Every passing conformance program projects to a portable mechanics IR before generation. The IR is an inspectable artifact, validated against payload contracts, rather than an internal detail.
05 Size, honestly
Across the conformance programs, the generated TypeScript is about 1.3 times as many lines as the Forma source, and roughly 1.6 times as many characters. Part of that is formatting. One program is longer in Forma than in TypeScript. Brevity is a side effect. The reasons to use Forma are what it checks and how it reports problems.
| Conformance case | Forma lines | Generated TypeScript lines | Ratio |
|---|---|---|---|
concurrent-workflow | 95 | 120 | 1.26× |
config-and-logging | 46 | 38 | 0.83× |
crud-users | 139 | 158 | 1.14× |
edge-cases | 106 | 152 | 1.43× |
elm-counter | 25 | 88 | 3.52× |
elm-pure | 7 | 33 | 4.71× |
http-api | 19 | 37 | 1.95× |
multi-service-checkout | 125 | 160 | 1.28× |
ontology-actions | 25 | 52 | 2.08× |
operational-effects | 11 | 24 | 2.18× |
pure-domain-logic | 133 | 84 | 0.63× |
resource-scope | 69 | 94 | 1.36× |
schemas-and-decoding | 80 | 78 | 0.97× |
stream-pipeline | 60 | 70 | 1.17× |
surface-review | 45 | 95 | 2.11× |
typed-errors | 59 | 76 | 1.29× |
| All cases | 1044 | 1359 | 1.30× |
06 Evidence
The Effect TypeScript conformance suite holds complete programs: a CRUD service with layers, a multi-service checkout, resource handling, a concurrent workflow, typed error recovery, schemas and decoding, configuration, and streams. For each one, CI checks five things:
exactOptionalPropertyTypes and noUncheckedIndexedAccess, with no any anywhere.A second set of programs must be rejected, each with exact, located diagnostics. Each rejection also records whether TypeScript would have caught the same mistake. Two rounds of adversarial testing, about 200 probe programs, found roughly 30 bugs in the compiler. All of them are fixed and now covered by tests.
07 The cost
Forma is a second language between you and Effect. These are the costs today, stated plainly. Some are fundamental to the approach and some are missing work.
Effect.promise, has to be a service implemented in TypeScript and provided as a layer.Effect.* combinators still require compiler support; imported compile-time libraries are a later stage. That contradicts Forma's own pitch that keywords are library code, and it is not resolved.Int; 0.0 is Float and starts a Float accumulator. Int arithmetic checks the safe integer range. Record literals widen like TypeScript's, so a schema value needs (Member {...}). A function cannot call a service, and let cannot run an effect.get-user becomes getUser), dashed fields stay quoted (order["total-cents"]), and the layout is the generator's.catch, so editors show false errors on valid Effect programs. There is no go-to-definition from generated code back to Forma.Schema.suspend), TaggedClass, transformations, Queue, PubSub, Deferred, Semaphore, scoped forks, schedule composition, Effect.fn, spans and tracing, and most of Stream. The reference lists what exists.generateEffectProgram from your own build script. Packages are 0.x, and the output shape changed in this release.makeMechanicsRuntime interprets only the original operation forms. Run the generated TypeScript instead.08 Open questions
These go beyond missing features. They are questions with no answer yet, and they could change the design.
An existing Effect codebase has schemas and services in TypeScript. Forma cannot refer to them, so today you would redeclare them. There is no importer in either direction.
It is undecided whether output is checked in and reviewed as a diff, or generated at build time and trusted. Each choice changes how teams review and how version upgrades land.
The target is exactly effect@4.0.3, a stable release. Following Effect's releases means re-validating the generator, and supporting two Effect versions at once has not been considered.
HTTP APIs, SQL, RPC, workers, and observability are where most Effect applications spend their code. A prelude-defined HttpApi spike generates endpoints, groups, checked handlers, and a TypeScript builder DSL for Effect 4. A separate Foldkit app prototype checks frontend commands, messages, update, and view; shared API client generation remains proposed. Middleware and the broader platform surface still sit behind hand-written service layers.
Tests are TypeScript harnesses written against the generated module. Forma has no way to write tests, test layers, or property checks in Forma itself.
The general HM checker and the Effect checker disagree on these programs. The live embeds on this page use the Effect checker for diagnostics, body types, and generation. Convergence and language-server routing are still open.
09 Fit
E and R to be the review.@effect/platform, SQL, RPC, or heavy npm use in business logic.The reference maps every form to the Effect code it generates. The conformance suite has complete programs with the TypeScript they produce.