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.
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:
// 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 chainNo 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:
// 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 usersVariables Enforcement
TypeScript enforces whether variables are required, optional, or forbidden:
// 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" }) // validPattern: Well-Typed Endpoints
For the best DX, define domain types explicitly and use utility types:
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:
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
- Defining APIs — defineApi, defineGroup, endpoint
- Error Handling — typed error data with HTTPError
- Validation — runtime schema validation