Architecture
End-to-end type-safe APIs without tRPC or codegen
API drift is the bug that ships on a Friday. The backend renames a field, the frontend keeps reading the old one, both compile, both deploy, and the error surfaces as undefined in a customer's browser. The industry has two standard answers: generate types from a schema (OpenAPI, GraphQL codegen) and keep the generation step in sync forever — or adopt tRPC and let the router's inferred type flow to the client.
There is a third option that is almost embarrassingly simple: put the contract in a shared module and import it from both sides. No codegen, no build step, no framework. This is the "T3 contract" pattern from ts-bedrock — T3 because in TypeFirst's TypeSpec, API contracts are the third layer of types, between database rows and frontend state.
The contract
A contract is a single type that pins down everything about an endpoint — method, route, URL params, body, every possible error code, and the success payload:
// Core/Api/Public/Login.ts — imported by BOTH server and client
export type Contract = Api<
"POST",
"/login",
NoUrlParams,
BodyParams,
ErrorCode,
Payload
>
export type BodyParams = {
email: Email // opaque, already validated
password: Password // opaque, already validated
}
export type ErrorCode = "USER_NOT_FOUND" | "INVALID_PASSWORD"
export type Payload = {
user: User
accessToken: AccessToken
refreshToken: RefreshToken
} Alongside the types, the contract carries decoders — runtime validators — for the body, URL params and response. Types are erased at runtime; decoders are how the contract stays honest when actual JSON arrives over the wire:
export const contract: Contract = {
method: "POST",
route: "/login",
urlDecoder: noUrlParamsDecoder,
bodyDecoder: bodyParamsDecoder,
responseDecoder: responseDecoder(errorCodeDecoder, payloadDecoder),
} The server fulfils it
On the backend, a handler is a pure async function from the contract's params to a Result of the contract's error code or payload. No req, no res, no Express in sight:
// Api/src/Api/Public/Login.ts
export async function handler(
params: API.BodyParams,
): Promise<Result<API.ErrorCode, API.Payload>> {
const { email, password } = params
const userRow = await UserRow.getByEmail(email)
if (userRow == null) return err("USER_NOT_FOUND")
const isValid = await Hash.verify(password.unwrap(), userRow.password)
if (isValid === false) return err("INVALID_PASSWORD")
return ok(await loginPayload(userRow))
} One generic adapter binds any contract + handler pair to Express: it decodes the request with the contract's decoders (rejecting anything malformed before your logic runs), calls the handler, and wraps the Result in the response envelope. Try to return err("USER_NOT_FOUD") — typo included — and it does not compile. Because handlers are plain functions, tests call them directly: no supertest, no HTTP mocking.
The client calls it
// Web/src/Api/Public/Login.ts
export async function call(params: BodyParams): Promise<Response> {
return publicApi(contract, {}, params)
} publicApi reads the method, route and decoders off the same contract object. The response is decoded at runtime — a malformed payload is a typed failure, not a mystery undefined — and error handling is exhaustive:
export function errorString(code: ApiError<ErrorCode>): string {
return apiErrorString(code, (errorCode) => {
switch (errorCode) {
case "USER_NOT_FOUND":
return "User is not found."
case "INVALID_PASSWORD":
return "Password is incorrect. Please try again."
}
})
} This is the part schema-codegen pipelines rarely give you: per-endpoint error codes as a closed union. Add a "ACCOUNT_LOCKED" code to the contract and this switch — and every other consumer — fails to compile until someone decides what the user should see. API evolution becomes a checklist the compiler walks you through.
What about tRPC?
tRPC is excellent, and if it fits your team, use it. The honest comparison:
- tRPC infers; contracts declare. tRPC's client types flow from the server implementation. Contracts are a standalone artifact both sides answer to — the API is designed as a type, not derived from code, and the contract file doubles as documentation.
- Typed errors. tRPC errors are a generic shape with a code field; closed per-endpoint error unions with exhaustive matching are the contract pattern's strongest card.
- Plain HTTP. Contracts describe ordinary REST-ish endpoints —
POST /login— callable from curl, mobile apps, or a different backend. No RPC conventions, no batching layer. - The cost. You write more: the contract, its decoders, and explicit client wrappers. tRPC gives you that for free — in exchange for a framework dependency on both ends and a TypeScript-only boundary.
The pattern needs no library at all — the entire Api type and its Express adapter are a few hundred lines you can read in one sitting. That is the real pitch: end-to-end type safety as a technique you own, not a dependency you adopt.