Skip to content
View as .md

Typed boundaries

Turn unknown into a typed value — res.json(), a Supabase select/RPC data, any payload you didn’t build here — with a Zod .parse(), never an as cast or a hand-rolled interface. A cast asserts a shape nobody checked; .parse() checks it, and both ends share one schema, so a mismatch is a parse error at the boundary instead of a TypeError later. It’s the Types rule at the JSON boundary; arbe’s wire schemas live in @arbe/core/schemas/.

const body = (await res.json()) as { id: string } // ✗ unchecked
const { id } = CreateThreadResponseSchema.parse(await res.json()) // ✓ checked, shared

Where schemas go:

  • Client envelopes (CreateThreadResponse, VersionInfo) → client-responses.ts.
  • Domain shapes → beside their domain (workflow.ts, usage.ts, secret.ts, sandbox.ts); a narrow select reuses RowSchema.pick({ … }).
  • www request bodies → readJsonBody(event, schema) (validate.ts), which 400s on bad input.

Move a local schema into core once a second surface needs it — don’t copy it.

A cast is still fine where the code can’t own a core schema:

  • Core-free packages like workflow-conductor — the www route that produces the data parses it, the sibling trusts that.
  • Generic transport (readStream<T>(), requestJson<T>()) — the caller names the schema.
  • Third-party payloads (GitHub latest.json, Tenor, sprites) — parsed where they enter, by whoever owns them.
  • Error envelopes you only stringify.

So parse a core schema when your code depends on @arbe/core and reads one of arbe’s own shapes — in practice www and core. Convert casts as you find them; a list in a doc just goes stale.