Architecture

End-to-end type-safe APIs without tRPC or codegen

TypeFirst · AgileLabs Engineering · 10 min read

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:

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.

Read the real thing. The contract type lives in Core/Data/Api.ts; the full login flow spans Core, Api and Web — about 130 lines total, end to end.

← All posts