Skip to content

Core ConceptsType Safety

Type Safety

How Courier achieves end-to-end type safety from API definitions to components — without code generation.

2 min readboth modes

Overview

Courier provides full type safety across the entire request lifecycle: from your API definition to the data in your components. No code generation, no schema files, no build step. TypeScript functions are the single source of truth.

The Builder Chain

Type safety starts with the chain. Each step that changes the endpoint's types returns a builder carrying them forward:

  • .response() — what the backend returns
  • .variables() — what the caller must pass. Absent, the endpoint takes no arguments.

Both take a schema, or a type argument when the API publishes no schema worth validating against.

api/users.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: string; name: string; email: string }
type UserList = { data: User[]; total: number }

export const users = defineGroup({
  prefix: "users",
  endpoints: {
    // Response only — the caller passes nothing
    getAll: endpoint.get("").response<UserList>(),

    // Variables the caller must pass, and the response they get back
    getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),

    // A write addresses the same wire through the mutation head
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
  },
})

The Inference Chain

Courier uses TypeScript's type inference to propagate types through dot-notation access:

typescript
// 1. You define the API
export const api = defineApi({ baseURL: "...", groups: { users } })

// 2. You create a typed query caller
const q = createProxyQueryCaller<typeof api>("api")

// 3. TypeScript infers everything at the call site
const { data: list } = q.users.useQuery("getAll")
//            ^? UserList — inferred from .response<UserList>()

const { data: user } = q.users.useQuery("getById", {
  variables: { id: "5" },
  //           ^? { id: string } — inferred from .variables<{ id: string }>()
})

const { mutate } = q.users.useMutation("create")
mutate({ name: "Jane", email: "jane@test.com" })
//      ^? { name: string; email: string } — inferred from the chain

No manual type annotations

You never need to write explicit response types. The response type is inferred automatically. Types stay in one place and are never duplicated.

Dot-Notation Validation

Group and method names are validated at the type level. TypeScript gives you autocomplete and compile-time errors:

typescript
// Group names are validated
caller.users.getAll()       // valid
caller.unknown.getAll()     // Type error: "unknown" is not a valid group

// Method names are validated
caller.users.getAll()       // valid
caller.users.unknown()      // Type error: "unknown" is not a method on users

Variables Enforcement

TypeScript enforces whether variables are required, optional, or forbidden:

typescript
// No variables — V defaults to undefined
endpoint.get("").response<User[]>()

caller.users.getAll()                          // valid
caller.users.getAll({ page: 1 })               // Type error — no variables expected

// Required variables
endpoint.get(":id").variables<{ id: string }>().response<User>()

caller.users.getById()                         // Type error — variables required
caller.users.getById({ id: 123 })              // Type error — id must be string
caller.users.getById({ id: "5" })              // valid

Pattern: Well-Typed Endpoints

For the best DX, define domain types explicitly and use utility types:

api/products.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type Product = { id: string; name: string; price: number; category: string }

type ProductFilters = {
  category?: string
  minPrice?: number
  maxPrice?: number
  page?: number
}

type CreateProductVars = Omit<Product, "id">
type UpdateProductVars = { id: string } & Partial<CreateProductVars>

export const products = defineGroup({
  prefix: "products",
  endpoints: {
    list: endpoint.get("").variables<ProductFilters>().response<Product[]>(),
    getById: endpoint.get(":id").variables<{ id: string }>().response<Product>(),
    create: endpoint.post("").variables<CreateProductVars>().response<Product>(),
    update: endpoint.patch(":id").variables<UpdateProductVars>().response<Product>(),
    remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
  },
})

// Everything flows automatically:
// q.products.useQuery("list", { variables: { category: "shoes" } })
//                                           ^? ProductFilters
// → data is Product[]

Schema-Inferred Types

When using runtime validation, types can be inferred from schemas — no manual generics needed:

typescript
import { z } from "zod"

const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string() })

// Types inferred from the schema — no type argument needed
endpoint.get("").response(z.array(UserSchema))
// → response type is { id: string; name: string; email: string }[]

Next Steps