Weave distributed systems from typed blocks. Compile down to idiomatic runtime code.
An exploration: describe distributed systems and their workflows in TypeScript, then compile that down to real, navigable code on a known-good runtime.
The "loom" metaphor: actions, sub-workflows, signals, timers, and triggers are the threads; you weave them together in plain TypeScript; the compiler produces the finished fabric — idiomatic Durable Functions code today, with other backends as future targets.
Status: research prototype. The APIs will change. None of it is production-tested. There are no support commitments. Issues and discussion are welcome; PRs accepted but reviewed sporadically since this is exploratory.
The premise we're testing is that distributed-system workflows — the things people reach for Temporal, Durable Functions, Inngest, Restate, or hand-rolled queue topologies for — can be expressed in a single platform-neutral source language, and then compiled (not interpreted at runtime) into idiomatic code for whatever runtime actually hosts them.
The author writes plain TypeScript using a small set of typed primitives. A build-time tool reads that source via the TypeScript compiler API, extracts a structured IR, and emits real, idiomatic, hand-readable code for the target runtime. The first (and currently only) target is Azure Durable Functions.
The research questions we care about:
-
Is a typed generator-based DSL a good shape for distributed workflows? Generators give us explicit suspension points (every
yield*is a checkpoint), let the workflow body read as straight-line imperative code, and produce a Command IR for free without owning the runtime. -
What primitives does the abstraction actually need? Rather than designing top-down, we wrote real-world scenarios in prose, then implemented them, and treated the friction as feedback on the primitive set.
-
Does ahead-of-time codegen beat runtime interpretation for this use case? The generated artifacts are committed, greppable, debuggable in a stack trace, and traceable via go-to-definition. No opaque runtime dispatch, no Proxy magic, no string-keyed event topics that hide who handles what.
-
Can the same author source map to multiple runtimes later? The current target is DF. Temporal, plain queues, or other backends become future emitters consuming the same IR. We haven't built a second emitter yet — but the shape of what we have anticipates one.
We own the compiler (TypeScript Compiler API + ts-morph + the TypeChecker), so we use it. The author writes pure TypeScript — no Zod imports, no .input() / .output() chains, no as any brand casts. The codegen reads the impl function's parameter and return types and derives the runtime validators at build time.
// schemas.ts — pure TS types
export type SignupForm = {
email: Email // branded → emits z.string().email()
password: MinLen<8> // branded → emits z.string().min(8)
}
export type UserResult = { userId: UserId }
// actions.ts
export const createUser = action('createUser')
.implement(async (form: SignupForm): Promise<UserResult> => { ... })What the codegen emits:
// generated/actions/createUser.ts
const __input = z.object({ email: z.string().email(), password: z.string().min(8) })
const __output = z.object({ userId: z.string().brand<'User'>() })
df.app.activity('createUser', {
handler: async (raw) => __output.parse(await decl.impl(__input.parse(raw))),
})Validation hints come from a small branded-type vocabulary in src/core/brands.ts: Email, Url, Uuid, Iso8601, MinLen<N>, MaxLen<N>, Pattern<S>, Int, Positive, NonNeg, Range<Lo,Hi>, Id<Tag>. The codegen recognizes them by intersection-shape and emits the corresponding Zod method. Brands compose: Email & MinLen<8> → z.string().email().min(8).
To construct a branded value, use the matching mint factory (mintEmail, mintId<'User'>, etc.). The factory runs the same validation the codegen would emit, so a runtime-built brand is guaranteed to satisfy the boundary check.
Type forms not representable as runtime schemas (function-valued properties, classes with private state, any / unknown in input/output positions) cause a hard codegen error with a clear message. The escape hatch isn't papered over.
- The author source uses zero target-platform vocabulary. No
durable-functionsimport, noTask, noOrchestrationContext. Those names appear only ingenerated/. - Generated code is committed, lives in a visible folder, reads like code a human would write, and carries provenance comments linking back to its source.
- Static find-references must work across the boundary. Activity/workflow names appear as string literals in generated DF calls (
context.df.callActivity("renderFull", ...)), so grep and IDE tooling can find every call site. - No runtime registry, no Proxy, no decorator-based discovery. Direct imports, static analysis, structural types.
- Compound primitives lower at compile time, not runtime.
parallel,waitForAny,withCompensation, andstartSubWorkfloweach lower to inline DF code (Task.all,Task.any, try/catch with a local compensation stack,callSubOrchestrator) — no Command-IR interpreter survives into the deployment artifact. The Command IR exists only for the in-process harness used during development. - Authoring-time errors over runtime errors. A
call(unknownAction, x)fails the codegen with a clear message, not at runtime.
src/core/ Author-facing library. Zero durable-functions imports.
action(), workflow(), trigger() builders + generator helpers.
src/codegen/ ts-morph-based scanner + emitter. Reads source, builds IR,
writes generated/ DF code.
src/harness/ In-memory interpreter. Runs workflows without Azure for
development, testing, and validating the IR is portable.
examples/scenarios/ Seven real-world scenarios — prose description plus a
working implementation against the DSL. Drives design
pressure on the primitive set.
examples/survey/ The original end-to-end working example.
generated/ Output of `pnpm codegen` — committed, navigable.
Author-facing primitives currently in the library:
| Primitive | Purpose |
|---|---|
action(name).implement(fn) |
A typed unit of work compiled to a DF activity. Input/output schemas are derived from the impl signature. |
workflow(name).define(function*(input: T) {...}) |
A workflow generator body. Input schema derived from the type of input. |
call(action, input) |
Invoke an action and yield its result; runtime auto-tracks subtask state |
parallel([call(...), ...]) |
Tuple-typed concurrent fan-out; lowers to context.df.Task.all |
waitForSignal<T>(name, opts) |
Pause until an external signal arrives, or timeout |
waitForAny({ k: signal/timer }) |
Discriminated-union wait over many concurrent sources; lowers to context.df.Task.any |
setState(value) |
Update the workflow's externally-observable status |
setSubtaskState(key, value) |
Custom per-subtask status (the runtime auto-tracks status for every call) |
withCompensation(forward, compensate) |
Saga-style LIFO compensating actions; orchestrator wraps body in try/catch that runs them on throw |
popCompensation() |
Retire the most recent compensation after a commit point |
continueAsNew(input) |
Restart workflow with fresh history |
startSubWorkflow(workflow, input) |
Compose another workflow as a child orchestration; lowers to callSubOrchestrator |
trigger(name).http/.schedule/.blob... |
Inbound boundary: starts a workflow or raises a signal |
pnpm install
pnpm codegen # scan source, emit generated/
pnpm typecheck # tsc --noEmit across source + generated
pnpm demo # run the survey workflow through the in-memory harness
# Per-scenario demos (use the harness, no Azure required):
pnpm exec tsx examples/scenarios/account-signup/demo.ts
pnpm exec tsx examples/scenarios/payment-with-3ds/demo.ts
pnpm exec tsx examples/scenarios/order-saga/demo.ts
pnpm exec tsx examples/scenarios/multi-party-approval/demo.tsThis is a research prototype. Things we have deliberately not built:
- A second emitter (Temporal, plain queues). The IR is shaped to allow it; we haven't proven it.
- A real business calendar resolver —
Nbd(business days) durations parse, but resolution is a stub. - Determinism linting — a future codegen pass that rejects non-deterministic calls (
Date.now(),Math.random(),fetch()) inside workflow bodies. - Per-trigger dedupe / debounce, per-customer fan-out at trigger time, per-approver concurrent timers — flagged with
[GAP]markers in scenario sources.
Run grep -rn '\[GAP\]' examples/ to see the open design questions the scenarios surfaced.