Client Types
Per-version wire types derived at the type level from your transform annotations — typed SDKs for every version, no codegen.
The idea
For a client pinned at version P on route R, the wire types are already
implied by the change chain: the request type is the first applicable
change's up() parameter (ups run oldest-first, and the client speaks the
oldest shape), and the response type is the first applicable change's
down() return (downs run newest-first, so the oldest change's down
produces what the client sees). No function composition needed — the type
layer filters the tuple and reads the endpoints:
import type { ClientTypes } from "@versionless/core";
const api = v.register([orgsToTeams, splitUserName] as const);
type OldWire = ClientTypes<typeof api, "2025-06-01">;
// OldWire["GET /users/:id"]["response"] -> UserV1 | UserV1[]
// = { id: string; email: string; name: string } (or an array of it)
// — exactly the down() return type of the 2026-05-14 change.
type Floor = ClientTypes<typeof api, "floor">;
// "floor" = before the first change: every transform applies.Note the response type above is a union: the demo's down is annotated
(body: User | User[]) => UserV1 | UserV1[] because it serves both the list
and detail routes. The derivation reads your annotations verbatim — write
per-route changes (or narrower annotations) if you want narrower types.
register([...] as const) carries the tuple for these derivations — keep it
in ascending version order (the runtime warns if not).
The contract you must hold up
- Annotate your transforms.
up: (body: OldShape): NewShape => ...anddown: (body: NewShape): OldShape => .... Unannotated transforms inferany/unknownand the derivation degrades accordingly. - Pin points are known versions.
KnownVersion<Api>is a registered change version,current, or"floor". There is no type-level date arithmetic; the runtime's nearest-release normalization has no type-level equivalent, so"2025-09-01"(not a release) is rejected at compile time even though the runtime would serve it. - Chains are validated. If change A's
upoutput isn't assignable to change B'supinput, the derived type is aChainErrorinstead of a silent lie.
Debugging a ChainError
When a chain doesn't compose, the route's derived type becomes
ChainError<Produced, Expected>
// { __versionless_chain_mismatch: { produced: Produced; expected: Expected } }so any use of it fails loudly. Example: change A's up returns
{ fullName: string } but the next change's up expects { name: string }:
type Broken = ClientTypes<typeof api, "floor">["POST /users"];
// ^? ChainError<{ fullName: string }, { name: string }>
// "produced" is what the earlier up() emits,
// "expected" is what the next up() wants — fix one of the two.Surface these in a type-test file so CI catches incompatible chains at tsc
time — see the test-d setup below.
Supplying current shapes
Core only knows the deltas. For routes with no applicable change (or to give
the current shape a name), pass a CurrentShape map as the third parameter:
import type { ClientTypes, CurrentShape } from "@versionless/core";
import type { Team, User } from "./db/schema";
type Shapes = {
"GET /users/:id": { request: never; response: User };
"GET /teams/:id": { request: never; response: Team };
};
type Current = ClientTypes<typeof api, "2026-07-21", Shapes>;
// Current["GET /users/:id"]["response"] -> User (no transforms at current)
// Current["GET /teams/:id"]["response"] -> Team (no changes touch the route)Derive these from your single parent source (drizzle/zod inference), never by hand — the typed SDK threads the same map through its second type parameter.
Jumps participate naturally: when a jump starts exactly at P, its annotated
endpoints are the wire types. Mixed jump+chain paths fall back to wire
declarations in v0.
Exports
From @versionless/core (all type-only):
| Export | What it is |
|---|---|
ClientTypes<Api, P, Shapes?> | Whole-API map: route key → { request, response } for pin P. |
RouteClientTypes<Cs, R, P, Shape?> | Single-route version of the same derivation. |
KnownVersion<Api> | The valid pin points: registered change versions, current, "floor". |
CurrentShape | { request?: unknown; response?: unknown } — one entry of the shapes map. |
ChainError<Produced, Expected> | The mismatch marker described above. |
CI type tests with expect-type
Wire-type regressions are tsc errors if you assert them. Add a test file
(bun runs it like any other test; the assertions are compile-time):
// test/wire-types.test-d.ts
import { test } from "bun:test";
import { expectTypeOf } from "expect-type";
import type { ClientTypes } from "@versionless/core";
import type { demoApi } from "@versionless/api/demo/versions";
import type { UserV1 } from "@versionless/api/demo/changes/2026-05-14-split-user-name";
type Old = ClientTypes<typeof demoApi, "2025-06-01">;
test("2025-06-01 clients get the pre-split user shape", () => {
expectTypeOf<Old["GET /users/:id"]["response"]>().toEqualTypeOf<UserV1 | UserV1[]>();
expectTypeOf<Old["POST /users"]["request"]>().toMatchTypeOf<{ name: string }>();
});If someone edits a shipped transform's annotations — or registers a change that breaks chain composition — this file stops compiling and CI goes red.
Typed SDK
@versionless/client — a tiny fetch client whose request and response types follow the version it's pinned to, derived from the server's change chain.
Telemetry & the Dashboard
Opt-in via API key — no key, no network calls; with a key, adoption curves, sunset blockers, and per-endpoint drift light up.