# 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](../../CLAUDE.md) at the JSON boundary; arbe's wire schemas live in [`@arbe/core/schemas/`](../../packages/core/schemas).

```ts
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`](../../packages/core/schemas/client-responses.ts).
- Domain shapes → beside their domain ([`workflow.ts`](../../packages/core/schemas/workflow.ts), `usage.ts`, `secret.ts`, `sandbox.ts`); a narrow `select` reuses `RowSchema.pick({ … })`.
- www request bodies → `readJsonBody(event, schema)` ([`validate.ts`](../../apps/www/src/lib/server/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`](../../apps/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.
