Skip to content

§7 Meaning by translation

Bynk has no operational semantics. A program’s dynamic meaning is defined by translation: it is the behaviour of the TypeScript the compiler emits for it, executed against the runtime library (§7.4). This chapter defines that translation per construct, and the two emission targets it varies over.

For every well-formed program (§5) the compiler emits TypeScript. The meaning of the program is the meaning of that TypeScript run against the runtime library. There is no second, independent definition of behaviour to reconcile against; where this chapter and the emitter disagree, that is a defect (§1.1). The emitted output is deterministic and each file is headed // Generated by bynkc — do not edit by hand.

A program is emitted for one of two targets, selected by --target (§8). A commons emits the same plain TypeScript — types and functions — on either target; the targets differ in how a context, its agents, and cross-context calls are realised.

bundle (default)workers
Layouta flat .ts tree mirroring the sourceone Worker directory per context
Cross-context calla direct, in-process callJSON over a Cloudflare Service Binding, validated at the boundary
Agentbacked by an in-process StateRegistrya Cloudflare Durable Object keyed by the agent key
Consumed adapterin-process, via its bindingin-process, via its binding — no Service Binding, no Env entry

An adapter is not a deployment unit: consuming one is in-process on both targets (§7.3.6). Only consumed contexts become Service Bindings under workers.

On the workers target a context with handlers additionally emits a router and boundary plumbing (index.ts), the handler logic (handlers.ts), the composition root (compose.ts), and a wrangler.toml; records that cross a boundary gain serialise_* / deserialise_* helpers. Cross-context data MUST be structurally validated as it crosses (§6.5).

Float at boundaries (v0.21, ADR 0040). Boundary Float values are finite, even though in-language arithmetic is host-defined (§5.2):

  • deserialise_ of a Float field requires typeof v === "number" && Number.isFinite(v), with no integer check (decimals are the point). JSON.parse("1e999") yields Infinity, which is rejected as a StructuralMismatch expecting a finite number — never admitted from the wire.
  • serialise_ of a Float field throws on a non-finite value (a contract violation): JSON.stringify(NaN) would otherwise silently produce null and break the round-trip.
  • v0.22b (ADR 0049): a bare Int field additionally requires Number.isInteger — at record/nested fields and workers handler params. A previously-accepted {qty: 1.5} now fails with a StructuralMismatch expecting an integer; with Float in the language there is no excuse for fractional Ints, and codec and .of agree (as 0040 aligned them for Float).
ConstructEmits
alias (type Id = Int)a branded base type with .of / .unsafe
refined typea branded base with .of (a runtime predicate check returning Result) and .unsafe
opaque typea branded base with constructors; no structural access to the representation
recordan interface with readonly fields, constructed from an object literal
sum / enuma discriminated union over a tag field, plus a constructor namespace

A brand is a compile-time TypeScript intersection (a phantom field); it is erased after type-checking and has no runtime representation. A sum type lowers as:

export type Status =
{ readonly tag: "Pending" }
| { readonly tag: "Shipped"; readonly tracking: string };
export const Status = {
Pending: { tag: "Pending" } as Status,
Shipped: (tracking: string): Status => ({ tag: "Shipped", tracking }),
};

All four base types lower to TypeScript primitives: Int and Float both emit number (v0.21 — the distinction is checker-side only and erased), String emits string, Bool emits boolean. A refined Int’s .of includes a Number.isInteger check; a refined Float’s .of includes Number.isFinite instead — validated Float values are finite (0040).

ConstructEmits
if … else …a conditional / if
matcha switch on .tag, payload fields bound as const
admitted literalT.unsafe(literal) (§6.4)
float literalthe source lexeme verbatim (1e10 does not normalise)
interpolated stringa template literal: chunks as escaped text (backslash, backtick, and $ escaped), each \(e) hole as ${String(<e>)} (ADR 0075)
Int / IntMath.trunc(a / b) — truncating, unchanged
Float / Floata / b — true division (v0.21, operand-typed)
numeric kerneli.toFloat() → the receiver (erased identity); f.round()/floor/ceil/truncateMath.round(f) / Math.floor(f) / Math.ceil(f) / Math.trunc(f); x.toString()String(x) (host number→string, ADR 0074)
?a check-and-early-return on Err
<-await
~>ctx.__exec.waitUntil(<effect>) — dispatched, not awaited

An Effect[T] is realised as a Promise<T>; it has no runtime constructor. Effect.pure(x) lowers to x directly where an async context absorbs it, and to Promise.resolve(x) in a synchronous or tail position. The <- bind is therefore await and needs no runtime support.

An asynchronous send (~>, §4.8.5) lowers on the Workers target to deps.__exec.waitUntil(<effect>): the effect’s Promise is handed to the execution context’s waitUntil rather than awaited, so it settles after the handler returns its response instead of being cancelled with it. The execution context is threaded from the entry point (fetch/scheduled/queue’s third argument) through compose(env, ctx) into the handler’s deps.__exec — but only for contexts that contain a send, so a context that never uses ~> emits byte-for-byte as before. This is the immediate delivery tier; a buffered/at-commit tier is reserved for the events channel and is not yet emitted.

An agent emits a state interface, a zero-value factory that realises the field zeros and initialisers of §5.4, and a class whose handlers read through loadState and write through commitState:

function __zeroOfCounterState(): CounterState { return { count: 0 }; }
// loadState(): return stored ?? __zeroOfCounterState();

On bundle an agent is constructed against a per-agent StateRegistry (a serialised-key-to-state map, reset between tests); on workers it is a Durable Object addressed by the agent key. A single makeAgent helper selects the path from whether a Durable Object binding is present, so call sites are identical across targets (§7.4).

Invariants (v0.80). When an agent declares invariants (§5.4.1), commitState(s) gates on each predicate (lowered as a pure expression over the proposed state s, with state fields read as s.<field> and implies as (!(P) || Q)) before storage.put. A failed predicate console.error-logs the agent type and invariant name — never the key value (ADR 0107) — and throws the dedicated invariantViolation(agent, invariant) fault, so the offending state is never written. The fault rides the existing uncaught-fault channel and surfaces to the caller as a 500-class fault, not an outcome:

private async commitState(s: OrderState): Promise<void> {
if (!((!(s.status === OrderStatus.Paid) || (s.paymentRef.tag === "Some")))) {
console.error("InvariantViolation Order.paid_has_payment_ref",
{ agent: "Order", invariant: "paid_has_payment_ref" });
throw invariantViolation("Order", "paid_has_payment_ref");
}
await this.state.storage.put("state", s);
}

On the workers target, each context with HTTP handlers emits handlers.ts (the handler logic), index.ts (the router and boundary validation), compose.ts (the wiring), and a wrangler.toml. A handler’s HttpResult[T] (§5.7) determines the HTTP status and body of the Response; records crossing the boundary are serialised and deserialised through the generated serialise_* / deserialise_* helpers.

§7.3.4a Actors & the verification seam (v0.45)

Section titled “§7.3.4a Actors & the verification seam (v0.45)”

An actor declaration emits no TypeScript — like a brand, it is a compile-time contract. The handler by clause (§5.7a) lowers through a per-scheme verification seam that mirrors the protocol descriptor: a scheme contributes its verification codegen, identity shape, and failure mapping behind one interface. The two zero-crypto schemes add no topology: None always admits, and Internal reuses the channel-trust assertion already implicit in the service-binding and platform-dispatch entry points — so a handler with a by clause emits byte-identically to one without. The bound identity (<binder>.identity) is minted at the seam; for the zero-crypto schemes it is the sealed unit value. Bearer (v0.47) extends the seam with real verification: the compose wrapper extracts Authorization: Bearer …, HS256-verifies the JWT (verifyBearerJwtHs256 in the runtime) against a secret sourced from env, enforces exp/nbf, and mints the identity by constructing the declared type from the sub claim — returning HttpResult.Unauthorized (401) fail-closed on any failure, before the body. The minted identity threads through the handler’s deps, so <binder>.identity reads the verified value. Signature (v0.51) verifies an HMAC over the request body for inbound webhooks. Because the signature is over the raw bytes, its seam sits in the entry dispatch (where the body is read): it reads the body once as text (await request.text()), sources the secret from env, recomputes HMAC-SHA256 and constant-time-compares (verifySignatureHmacSha256 in the runtime, via crypto.subtle.verify) against the configured signature header (accepting a bare hex digest or a sha256=<hex> prefix), optionally checks a signed timestamp is within tolerance of now (binding <timestamp>.<body> as the signed string), then hands the same text to body deserialisation — never a re-read or a re-serialisation. Any failure → HttpResult.Unauthorized (401), fail-closed, before the body. A Signature actor carries no identity.

A multi-actor sum (by who: A | B, v0.52) composes these seams under first-wins resolution in a single boundary wrapper, which owns the whole boundary so the request is read once. When any member verifies over the body (a Signature peer) or the handler takes a body, it reads the raw body once as text; it then tries each member’s scheme in declared order — a Bearer peer against the Authorization header, a Signature peer against those held bytes, a None peer accepting unconditionally — and binds the first success into a tagged { tag: "<Actor>", identity?: … } value threaded through deps.who, which the body matches. If no member verifies, the wrapper returns HttpResult.Unauthorized (401) fail-closed, before the body; otherwise it parses the body param from the same bytes and dispatches. No member re-reads the request — composing a header member with a body member never re-reads or re-serialises.

A refinement actor (actor Admin = User where <claim predicate>, v0.53) adds an authorisation check to its base’s seam. The Bearer base verifies as above (failure → 401); the seam then surfaces the verified claims (verifyBearerJwtHs256 now returns them alongside sub) and evaluates the lowered claim predicate against them — a failed invariant returns HttpResult.Forbidden (403, distinct from the 401 authentication channel), before the identity mints or the body runs. The claims are an authorisation-time input only: the body still sees just the sealed base identity (<binder>.identity), so claims are not threaded into deps.

The Caller actor (on call … by c: Caller, v0.54) mints a live CallerId over the cross-context Service Binding. The call site (callService) stamps the calling context’s qualified name — a compile-time constant — into a reserved X-Bynk-Caller header beside the (unchanged) args body. The callee’s /_bynk/call/<service> dispatch reads the header, rejects fail-closed if it is absent or empty (the internal analogue of 401), and threads the name into the handler’s deps as the CallerId identity, so <binder>.identity lowers to deps.identity (replacing the undefined placeholder). Verification is channel-based — no crypto — and a binder-less on call reads no header and is byte-unchanged.

Each test unit emits a per-target test module; an aggregating runner (tests/main.ts) collects the module results. An assert (§5.9) lowers to a runtime check that throws on failure, which the runner records as a failing case. bynkc test emits these modules, compiles them with tsc, and runs the aggregated runner on Node (§8.4).

ConstructEmits
function type A -> Bthe TS function type (a0: A) => B (an Effect return is Promise via the ordinary lowering)
lambdaa TS arrow — async exactly when its checked type is effectful; written annotations transcribe, omitted ones rely on TS contextual typing in argument position
lambda block bodythe same statement lowering as a function body
named function as a valuethe function’s TS identifier, unchanged
value applicationa TS call
generic functionan erased TS generic (function name<A, B>(…)) — no runtime type-argument dispatch

An adapter’s contract emits like a context’s: each capability becomes a TypeScript interface plus an injection token, and its types emit per §7.3.1 into the adapter’s module (<adapter>.ts). An external provider emits no class — its implementation is the class of the same name that the binding module MUST export, and implements <Interface> against the generated interface is the contract between the two halves, checked by the tsc --strict gate (§8.4).

The binding module is copied verbatim into the output beside the adapter’s emitted module, so its imports resolve and the gate checks it. Its declared requires dependencies are folded into a generated package.json.

The composition root instantiates an external provider from the binding module’s namespace. A provider with a given clause receives a by-name deps object: each key is the given name, each value the recursively instantiated provider of that capability — a bare name resolving through the provider’s own unit’s flattened consumes (§5.8), so an adapter’s dependency on another adapter pulls that adapter’s binding into the same compose, transitively:

const Jwt = new tokens__binding.JoseJwt({
Secrets: new bynk__binding.SecretsProvider(),
});

Provider selection is per build — test mocks override a local provides, which overrides the adapter default — but instances are per-compose: each consuming context constructs its own.

The first-party bynk adapter — the ambient surface: Clock, Random, Logger, Fetch, Secrets — is injected as a synthetic unit when any unit consumes it, and flows through this same pipeline. It has no binding clause; the toolchain supplies one per platform (bynk-<platform>.ts, selected by --platform, §8.5). Because the contract names canonical provider symbols, the emitted compose is platform-identical — only the imported binding module differs. On the workers target the compose passes the Worker env to the first-party providers that take it (Secrets); on bundle the binding falls back to a globalThis probe of process.env.

A platform adapter (v0.19: bynk.cloudflare, exporting Kv; v0.23 extends it — get/put/putTtl/delete/list) is injected the same way, with one toolchain-supplied binding copied to bynk/cloudflare.binding.ts. putTtl passes { expirationTtl } to the namespace (0051). list(prefix: Option[String]) -> Effect[List[String]] is a binding-side drain (0050): the cursor loops inside the host binding (env.KV.list({ prefix, cursor }) until list_complete, projecting keys[].name) because no Bynk routine can both recurse and hold a capability — the given-on-free-functions gap, recorded for a future increment. The drain is eager and unbounded, normatively: a very large namespace loads every matching key; cursor-paging is deferred until the language can consume it. The runtime KVNamespace interface (§7.4) carries the matching list page shape and put options parameter. Its resources exist only on the Worker env — there is no globalThis path — so its binding reads env.KV explicitly and throws a clear error when the binding is absent. The compiler’s work is derived, not injected: when a deployment unit’s closure reaches the adapter, the Worker’s Env gains a typed KV: KVNamespace field and its wrangler.toml a [[kv_namespaces]] stanza (one fixed KV binding name; the namespace id is a deploy-time placeholder). On the bundle target, composeApp gains an optional env?: unknown parameter — threaded to env-taking providers — only when a platform-native resource is consumed; native-free programs emit the parameterless signature unchanged. Consuming a platform adapter locks the deployment unit to its platform (§5.8).

(v0.20b) The collection types lower to immutable TypeScript shapes:

ConstructEmits
List[T]readonly T[]
Map[K, V]ReadonlyMap<K, V>
[a, b, c]the array literal [a, b, c]
List.empty() / Map.empty()[] as readonly T[] / new Map<K, V>(), with the checked type arguments written out

Kernel operations emit inline — typed IIFEs and spreads, no runtime imports — so a module that never touches collections emits byte-identically to v0.20a. prepend is the spread [x, ...xs]; insert copies (new Map(m).set(k, v)) — the emitted value is never mutated in place. fold and foldEff emit as a single loop (an IIFE; async for foldEff, awaiting each step in sequence) — iteration is the kernel’s, so no user-visible recursion or stack growth exists. Local mutation inside that loop is permitted; it never escapes.

At boundaries, a List[T] serialises element-wise as a JSON array; a Map[K, V] serialises as an entries array [[k, v], …] — uniform across String and Int keys (a JSON object could not carry Int keys), and insertion-ordered, normatively. Per-instantiation helpers (serialise_List_<T>, deserialise_Map_<K>_<V>) follow the existing Result/Option pattern: element and entry deserialisation validates structurally, re-validates refined types, and reports StructuralMismatch with an indexed path ($.orders[3].tags[0][1]).

The v0.22a kernel methods lower inline, like the collection kernel — no new runtime imports beyond the Some/None/Ok/Err constructors every module already has:

ConstructEmits
string opsthe TS string method (trim, split, includes, startsWith, …); s.length().length; toUpper/toLowertoUpperCase/toLowerCase
s.replace(a, b)replaceAll(a, b) — replace-all, normatively
s.chars()[...s] — code points, normatively
s.slice(lo, hi)slice(Math.max(0, lo), Math.max(0, hi)) — negatives clamp, no wrap
s.indexOf(sub)a typed IIFE turning -1 into None, else Some(i)
Option/Result combinatorstyped IIFEs branching on .tag; the miss branch returns the narrowed receiver or None
numeric abs/min/maxMath.*
x.clamp(lo, hi)Math.min(Math.max(x, lo), hi)
f.isNaN()/f.isFinite()Number.isNaN/Number.isFinite
Int.parse/Float.parsea typed IIFE over Number(s) (full-string), rejecting empty/whitespace input, non-safe-integers (Int) and non-finite values (Float)

Json.encode(v) lowers to JSON.stringify(serialise_<T>(v)) for the value’s checked type; Json.decode[T](s) to a typed IIFE that JSON.parses (a throw becomes a Malformed JsonError), dispatches to deserialise_<T>, and maps a BoundaryError into the uniform kind/path/message record (ADR 0047). The per-type serialise_/deserialise_ helpers and any generic instantiations (deserialise_List_Order, …) are emitted module-locally into each module whose code calls the codec — the same closure machinery as the workers boundary path, deduped against helpers that path already emitted. The codec runtime types (JsonError, JsonValue, BoundaryError) are imported only by modules that use the codec, so non-codec modules emit byte-identically to v0.22a.

§7.3.10 Streams and WebSockets (v0.100, v0.102+)

Section titled “§7.3.10 Streams and WebSockets (v0.100, v0.102+)”

A Stream[T] lowers to a host AsyncIterable<T>, emitted inline as async-generator IIFEs with no runtime import, so non-stream modules stay byte-identical: Stream.of(xs) becomes a generator over the list, map/take wrap it lazily, and the terminal collect() drains it into an array. A streamed HTTP responseStreaming(stream) — lowers to a Response whose body is the stream encoded as a Server-Sent-Events (SSE) byte stream consuming a Stream[String].

A Connection[F] lowers against the runtime Connection<F> interface (§7.4.9): send JSON-encodes a frame, close ends the socket. The from WebSocket protocol lowers per target:

  • bundle — the on open/on message/on close handlers become callable surface methods (Service.open(conn, …)) taking a TestConnection — a capture-and-inspect channel recording every frame sent — so the service runs under Node with no Durable Object.
  • Workers — the Worker authenticates the upgrade at the edge (the same JWT verifier the HTTP seam uses, reading the Bearer token from the Sec-WebSocket-Protocol subprotocol) and forwards it to the addressed Durable Object, which accepts the socket via the hibernatable-WebSocket API (acceptWebSocket/serializeAttachment/getWebSockets). A held Map[K, Connection] cannot be JSON-persisted (a live socket), so it lives in an in-memory side-table split out of the durable record. parTraverse over the map lowers to await Promise.all(xs.map(f)). Only modules that use streams or WebSockets emit this machinery.

Every emitted project ships a single runtime module that the per-context and per-test modules import. It is the normative contract the emitted code depends on, defined in §7.4 The runtime library.