How the Type Bus Works
Sync arbitrary TypeScript types from your API repo to your frontends — no OpenAPI, no DTOs, no hand-copying.
OpenAPI describes what crosses the wire. It does not describe everything your frontend needs to know.
You have a Map<StudentId, Grade> on the backend. A discriminated union for realtime channel kinds. A CASL action list. None of it belongs in a response schema, all of it belongs in your frontend's type system. The usual options are both bad: re-model each one as a DTO with decorators (busywork, and OpenAPI can't express Map or generics or template literals anyway), or copy the types by hand and watch them drift.
The Type Bus is the third option. Mark a type on the backend, run fetch on the frontend, import it. No OpenAPI involved.
The Type Bus is entirely optional. No [bus] block in api.config.toml means no bus work happens at all — every existing command behaves exactly as before.
The one idea that makes it work
A type's serializable form is its source text.
That sounds too simple, so consider the alternative. If you convert types into some intermediate representation — a Zod schema, a JSON-schema document, a custom AST — you immediately lose the constructs that made you want the bus in the first place: generics, mapped types, conditional types, template literals, Map. You'd rebuild OpenAPI's limitations with extra steps.
Declaration text is lossless by construction. Whatever TypeScript can express, the bus can carry, because the bus carries the TypeScript you wrote.
That choice explains everything else on this page: why validation is strict (text becomes code in your frontend), why only types ride (a function body is executable), and why the wire format is a boring JSON envelope around strings.
The pipeline
API repo Frontend repo
──────────────────────────────────────── ─────────────────────────────
*.chowbea.ts barrels ─┐
@chowbea-export tags ─┴─► extract ──► chowbea.bus.json
│
busHandler serves it
GET /.well-known/chowbea.json
│
└──► fetch ──► _generated/bus/*.ts
│
import type { GradeMap }Four moving parts:
Mark
Put types in a *.chowbea.ts barrel, or tag them @chowbea-export.
Extract
A build-time CLI pass reads your real tsconfig and emits a manifest.
Serve
A three-line handler serves the manifest with ETag support.
Consume
fetch pulls it alongside the spec and emits mirrored .ts files.
Why extraction happens at build time
Types don't exist at runtime. By the time your API server is running, TypeScript has been erased — there is nothing left to reflect over. So extraction reads your source with the TypeScript compiler API, at build time, and bakes the result into an artifact that ships with your deploy.
That's why extract belongs in your build script, and why the served endpoint is just a file reader. It also means the same mechanism works identically in dev, staging, and production — only the URL differs.
The wire format
chowbea.bus.json is chowbea's own spec, versioned independently of OpenAPI:
{
"chowbeaBus": "1",
"generatedAt": "2026-08-15T09:12:44.001Z",
"hash": "9f2c…",
"barrels": {
"exams/grade": [
{
"name": "GradeMap",
"kind": "type",
"declaration": "export type GradeMap = Map<StudentId, Grade>;",
"source": "src/exams/grade.chowbea.ts",
"line": 3,
"hash": "4ab1…"
}
],
"_marked/billing": [ /* … */ ]
}
}| Field | Purpose |
|---|---|
chowbeaBus | Format version. A CLI meeting an unknown version fails loud telling you to upgrade — it never guesses. |
generatedAt | Timestamp. Lives only in the artifact, never in emitted files — so generated code has no volatile fields to conflict on. |
hash | Content hash of all barrels. Doubles as the HTTP ETag and as the frontend's "nothing changed" check. |
barrels | Map of barrel key → type entries. Keys drive the frontend's file layout. |
declaration | The verbatim source text. This is the payload. |
source / line | Where it came from, so generated files can cite their origin. |
Barrel keys come from the file's path relative to your tsconfig rootDir, minus the suffix: src/exams/grade.chowbea.ts → exams/grade. Types marked with @chowbea-export group under a reserved _marked/<dir> key. A file can override its key — and therefore its generated filename — with a chowbea-name directive; each entry's source still records where the type really came from.
chowbea.bus.json is a build output. Don't commit it — it's regenerated from source on every build, which is exactly why the served manifest can never drift from the deployed code.
What the bus guarantees
The bus is intentional. Nothing rides it implicitly. Every type is explicitly marked, and every type a bus type references must itself be on the bus (the closed-world rule). You never discover that a Prisma model got dragged into your frontend bundle because something three levels down referenced it.
Breakage is compile-time, not runtime. Rename a type on the backend and every frontend call site fails to compile after the next fetch. That's the entire point — the failure arrives in your editor, not in production.
Untrusted input is treated as untrusted. The manifest arrives over HTTP and its contents become .ts files your app compiles. So the frontend validates every declaration before writing it: exactly one exported declaration, matching the entry's name and kind, enum members restricted to literal values, hashes recomputed and verified, barrel keys rejected if they contain path-traversal segments. A compromised or misconfigured endpoint cannot inject executable code into your build.
Types only. Type aliases, interfaces, and enums ride the bus. Classes, functions, and consts are rejected at extraction with an error — see why if that surprises you.
When to use it — and when not to
| Situation | Use |
|---|---|
Shared domain vocabulary, Map/generic/union types the spec can't express | Type Bus |
| Request/response shapes for real endpoints | OpenAPI — you already get these |
| Enum values you need at runtime on the frontend | Either — the client generator emits enum consts from the spec, and enums ride the bus too |
| Shared logic (validators, formatters, business rules) | Neither. Publish a shared package. The bus copies declarations; you want one implementation, not two. |