§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.
§7.1 The model
Section titled “§7.1 The model”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.
§7.2 Targets
Section titled “§7.2 Targets”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 | |
|---|---|---|
| Layout | a flat .ts tree mirroring the source | one Worker directory per context |
| Cross-context call | a direct, in-process call | JSON over a Cloudflare Service Binding, validated at the boundary |
| Agent | backed by an in-process StateRegistry | a Cloudflare Durable Object keyed by the agent key |
| Consumed adapter | in-process, via its binding | in-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 aFloatfield requirestypeof v === "number" && Number.isFinite(v), with no integer check (decimals are the point).JSON.parse("1e999")yieldsInfinity, which is rejected as aStructuralMismatchexpecting afinite number— never admitted from the wire.serialise_of aFloatfield throws on a non-finite value (a contract violation):JSON.stringify(NaN)would otherwise silently producenulland break the round-trip.- v0.22b (ADR 0049): a bare
Intfield additionally requiresNumber.isInteger— at record/nested fields and workers handler params. A previously-accepted{qty: 1.5}now fails with aStructuralMismatchexpecting aninteger; withFloatin the language there is no excuse for fractionalInts, and codec and.ofagree (as 0040 aligned them forFloat).
§7.3 Construct emission
Section titled “§7.3 Construct emission”§7.3.1 Types
Section titled “§7.3.1 Types”| Construct | Emits |
|---|---|
alias (type Id = Int) | a branded base type with .of / .unsafe |
| refined type | a branded base with .of (a runtime predicate check returning Result) and .unsafe |
| opaque type | a branded base with constructors; no structural access to the representation |
| record | an interface with readonly fields, constructed from an object literal |
| sum / enum | a 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).
§7.3.2 Expressions
Section titled “§7.3.2 Expressions”| Construct | Emits |
|---|---|
if … else … | a conditional / if |
match | a switch on .tag, payload fields bound as const |
| admitted literal | T.unsafe(literal) (§6.4) |
| float literal | the source lexeme verbatim (1e10 does not normalise) |
| interpolated string | a template literal: chunks as escaped text (backslash, backtick, and $ escaped), each \(e) hole as ${String(<e>)} (ADR 0075) |
Int / Int | Math.trunc(a / b) — truncating, unchanged |
Float / Float | a / b — true division (v0.21, operand-typed) |
| numeric kernel | i.toFloat() → the receiver (erased identity); f.round()/floor/ceil/truncate → Math.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.
§7.3.3 Agents
Section titled “§7.3.3 Agents”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);}§7.3.4 HTTP services
Section titled “§7.3.4 HTTP services”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.
§7.3.5 Tests
Section titled “§7.3.5 Tests”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).
§7.3.5a Functions as values (v0.20a)
Section titled “§7.3.5a Functions as values (v0.20a)”| Construct | Emits |
|---|---|
function type A -> B | the TS function type (a0: A) => B (an Effect return is Promise via the ordinary lowering) |
| lambda | a 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 body | the same statement lowering as a function body |
| named function as a value | the function’s TS identifier, unchanged |
| value application | a TS call |
| generic function | an erased TS generic (function name<A, B>(…)) — no runtime type-argument dispatch |
§7.3.6 Adapters
Section titled “§7.3.6 Adapters”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).
§7.3.7 Collections
Section titled “§7.3.7 Collections”(v0.20b) The collection types lower to immutable TypeScript shapes:
| Construct | Emits |
|---|---|
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]).
§7.3.8 The stdlib kernels (v0.22a)
Section titled “§7.3.8 The stdlib kernels (v0.22a)”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:
| Construct | Emits |
|---|---|
| string ops | the TS string method (trim, split, includes, startsWith, …); s.length() → .length; toUpper/toLower → toUpperCase/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 combinators | typed IIFEs branching on .tag; the miss branch returns the narrowed receiver or None |
numeric abs/min/max | Math.* |
x.clamp(lo, hi) | Math.min(Math.max(x, lo), hi) |
f.isNaN()/f.isFinite() | Number.isNaN/Number.isFinite |
Int.parse/Float.parse | a typed IIFE over Number(s) (full-string), rejecting empty/whitespace input, non-safe-integers (Int) and non-finite values (Float) |
§7.3.9 The typed JSON codec (v0.22b)
Section titled “§7.3.9 The typed JSON codec (v0.22b)”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 response — Streaming(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 closehandlers become callable surface methods (Service.open(conn, …)) taking aTestConnection— 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-Protocolsubprotocol) and forwards it to the addressed Durable Object, which accepts the socket via the hibernatable-WebSocket API (acceptWebSocket/serializeAttachment/getWebSockets). A heldMap[K, Connection]cannot be JSON-persisted (a live socket), so it lives in an in-memory side-table split out of the durable record.parTraverseover the map lowers toawait Promise.all(xs.map(f)). Only modules that use streams or WebSockets emit this machinery.
§7.4 The runtime library
Section titled “§7.4 The runtime library”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.