@lokalise/api-contracts
v9.0.1
Published
Readme
api-contracts
API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route — its path, HTTP method, and request/response schemas — and serves as the single source of truth for both sides.
The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This eliminates assumptions across the boundary and keeps documentation, validation, and types in sync.
Defining contracts
REST routes
import { defineApiContract, noBodyResponse } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
// GET with path params
const getUser = defineApiContract({
visibility: 'public',
summary: 'Get user',
method: 'get',
requestPathParamsSchema: z.object({ userId: z.uuid() }),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: {
200: z.object({ id: z.string(), name: z.string() }),
},
})
// POST
const createUser = defineApiContract({
visibility: 'public',
summary: 'Create user',
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
responsesByStatusCode: {
201: z.object({ id: z.string(), name: z.string() }),
},
})
// DELETE with no response body
const deleteUser = defineApiContract({
visibility: 'public',
summary: 'Delete user',
method: 'delete',
requestPathParamsSchema: z.object({ userId: z.uuid() }),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: {
204: noBodyResponse(),
},
})Non-JSON responses
Use blobResponse for any non-JSON response — text-based (plain text, CSV, HTML, XML, YAML, etc.) or binary (images, PDFs, etc.). It records the response content-type in the contract and hands the consumer a lazy BlobResponseHandle, so the caller decides how to consume the body — aggregate it (await body.blob() / .text() / .arrayBuffer()), pipe the raw body.stream(), or body.cancel() it. The body is one-shot: the first accessor consumes it, a second throws.
import { defineApiContract, blobResponse } from '@lokalise/api-contracts'
const exportCsv = defineApiContract({
visibility: 'public',
summary: 'Export users as CSV',
method: 'get',
pathResolver: () => '/export.csv',
responsesByStatusCode: { 200: blobResponse('text/csv') },
})
const downloadPhoto = defineApiContract({
visibility: 'public',
summary: 'Download user photo',
method: 'get',
pathResolver: () => '/photo.png',
responsesByStatusCode: { 200: blobResponse('image/png') },
})Multiple content types
When a single status code can return more than one media type, map it to a content object keyed
by media type. Each value is a body descriptor: a bare Zod schema (JSON), blobBody() (opaque
binary or text), or sseBody() (Server-Sent Events). Add allowNoBody: true to also accept an
empty body.
import { defineApiContract, blobBody, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
const downloadReport = defineApiContract({
visibility: 'public',
summary: 'Download report',
method: 'get',
pathResolver: () => '/report',
responsesByStatusCode: {
200: {
description: 'Report in the requested format',
content: {
'application/json': z.object({ rows: z.array(z.string()) }),
'application/vnd.api+json': z.object({ data: z.object({ rows: z.array(z.string()) }) }),
'text/csv': blobBody(),
'application/pdf': blobBody(),
'text/event-stream': sseBody({ row: z.object({ value: z.string() }) }),
},
allowNoBody: true,
},
},
})Media types are matched exactly — parameters stripped, case-insensitive — so distinct keys such
as application/json and application/vnd.api+json never collide, and a single status code may
expose any number of media types (including several JSON variants). The shape maps 1:1 to the
OpenAPI Response Object. The matched media type is not surfaced on the client response; read it
from headers['content-type'] if you need to discriminate.
SSE and dual-mode routes
Use sseResponse() inside responsesByStatusCode for an SSE-only response. For a route that
returns either JSON or an SSE stream depending on the Accept header, use a content map (above)
with both an application/json and a text/event-stream entry.
import { defineApiContract, sseResponse, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
// SSE-only
const notifications = defineApiContract({
visibility: 'public',
summary: 'Stream notifications',
method: 'get',
pathResolver: () => '/notifications/stream',
responsesByStatusCode: {
200: sseResponse({
notification: z.object({ id: z.string(), message: z.string() }),
}),
},
})
// Dual-mode: JSON response or SSE stream depending on Accept header
const chatCompletion = defineApiContract({
visibility: 'public',
summary: 'Create chat completion',
method: 'post',
pathResolver: () => '/chat/completions',
requestBodySchema: z.object({ message: z.string() }),
responsesByStatusCode: {
200: {
content: {
'application/json': z.object({ text: z.string() }),
'text/event-stream': sseBody({
chunk: z.object({ delta: z.string() }),
done: z.object({ finish_reason: z.string() }),
}),
},
},
},
})Wildcard and default response keys
In addition to exact status codes, responsesByStatusCode accepts OpenAPI-style range keys ('1xx'–'5xx') and 'default' as fallbacks.
Lookup precedence at runtime: exact code → range key → 'default'.
import { defineApiContract } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
// '2xx' covers all 200–299 responses
const listItems = defineApiContract({
visibility: 'public',
summary: 'List items',
method: 'get',
pathResolver: () => '/items',
responsesByStatusCode: {
'2xx': z.object({ items: z.array(z.string()) }),
'4xx': z.object({ message: z.string() }),
},
})
// exact code takes precedence over the range key
const createItem = defineApiContract({
visibility: 'public',
summary: 'Create item',
method: 'post',
pathResolver: () => '/items',
requestBodySchema: z.object({ name: z.string() }),
responsesByStatusCode: {
201: z.object({ id: z.string() }),
'4xx': z.object({ message: z.string() }),
},
})
// 'default' matches any status code not covered by a more specific entry
const flexible = defineApiContract({
visibility: 'public',
summary: 'Get data',
method: 'get',
pathResolver: () => '/data',
responsesByStatusCode: {
200: z.object({ data: z.unknown() }),
default: z.object({ error: z.string() }),
},
})The '2xx' range key participates in SSE detection and success/error type narrowing exactly like explicit 2xx codes: InferNonSseClientResponse maps it to SuccessfulHttpStatusCode, and hasAnySuccessSseResponse returns true when it holds an SSE schema.
'default' is split into a success half (SuccessfulHttpStatusCode) and a non-success half in InferSseClientResponse / InferNonSseClientResponse so that captureAsError type narrowing stays correct regardless of the actual status code.
OpenAPI response descriptions
All response factories accept an optional ResponseOptions object as their last argument.
import { defineApiContract, noBodyResponse, blobBody, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
const contract = defineApiContract({
visibility: 'public',
summary: 'Upload file',
method: 'post',
pathResolver: () => '/files',
requestBodySchema: z.object({ name: z.string() }),
responsesByStatusCode: {
201: z.object({ id: z.string() }).describe('Created resource'),
204: noBodyResponse({ description: 'Deleted — no content returned' }),
200: {
description: 'Multiple response formats available',
content: {
'application/json': z.object({ id: z.string() }).describe('JSON representation'),
'text/csv': blobBody(),
'application/pdf': blobBody(),
'text/event-stream': sseBody({ update: z.object({ id: z.string() }) }),
},
},
},
})A content-map entry carries a single description for the whole response; per-media descriptions
aren't supported (a JSON descriptor can still carry its own via .describe()).
getSseSchemaByEventName(contract) extracts SSE event schemas from a contract:
import { getSseSchemaByEventName } from '@lokalise/api-contracts'
getSseSchemaByEventName(notifications)
// { notification: ZodObject<...> }
getSseSchemaByEventName(chatCompletion)
// { chunk: ZodObject<...>, done: ZodObject<...> }Route visibility
visibility marks who a route is intended for: 'public' or 'internal'.
Internal routes (e.g. backend-for-frontend endpoints consumed only by our own frontends) are
excluded by OpenAPI generators from the published API document. Visibility has no runtime
effect — the route is registered and served as usual.
const contract = defineApiContract({
summary: 'Editor autosave (BFF)',
method: 'post',
pathResolver: () => '/editor/autosave',
requestBodySchema: z.object({ content: z.string() }),
responsesByStatusCode: { 204: noBodyResponse() },
visibility: 'internal', // excluded from generated OpenAPI docs
})The field is required in every contract; there is no default. defineApiContract demands an
explicit choice between 'public' and 'internal'.
Field visibility
Zod schemas can also mark individual fields with visibility metadata. The package augments
Zod v4's global metadata type, so the value is checked against the same 'public' | 'internal'
choices used for routes:
import { z } from 'zod/v4'
const userSchema = z.object({
id: z.uuid(),
name: z.string(),
email: z.email().meta({ visibility: 'public' }),
internalNotes: z.string().meta({ visibility: 'internal' }),
})This metadata describes intent; it does not change Zod parsing or filter fields by itself. Other libraries can inspect it to hide or show fields in generated OpenAPI documentation, or to decide which fields to include in a response based on where the request came from (for example, an internal service or a public client).
Contracts for external (third-party) APIs
Contracts are also used to describe APIs we only consume, not serve. Mirror the endpoint's own
nature: use visibility: 'public' for publicly exposed endpoints, and visibility: 'internal'
when you were granted access to a private endpoint (e.g. as a partner with access to private or
beta APIs). Visibility is only ever acted upon by the serving side — the HTTP clients ignore the
field entirely, so it has no effect on a contract used purely as a client.
const createResource = defineApiContract({
summary: 'Create a resource in the external service',
method: 'post',
pathResolver: () => '/v1/resources',
requestBodySchema: createResourceRequestSchema,
responsesByStatusCode: { 201: createResourceResponseSchema },
visibility: 'public', // external API we consume — always 'public'
})All fields
type ApiContractOptions = {
// Required
method: 'get' | 'post' | 'put' | 'patch' | 'delete'
pathResolver: (pathParams: Record<string, string>) => string
// Human-readable summary; surfaced in fe/be http-client errors for debugging.
summary: string
// Accepts exact codes, OpenAPI-style range keys ('1xx'–'5xx'), and a catch-all 'default'.
// Lookup precedence at runtime: exact code → range key → 'default'.
responsesByStatusCode: Partial<
Record<
HttpStatusCode | '1xx' | '2xx' | '3xx' | '4xx' | '5xx' | 'default',
z.ZodType | ResponseEntry
>
>
// Path params — links pathResolver parameter type to the schema
requestPathParamsSchema?: z.ZodObject<z.ZodRawShape>
// Request
requestBodySchema?: z.ZodType | ContractNoBody // required for POST / PUT / PATCH, forbidden otherwise
requestQuerySchema?: z.ZodObject<z.ZodRawShape>
requestHeaderSchema?: z.ZodObject<z.ZodRawShape>
// Response
responseHeaderSchema?: z.ZodObject<z.ZodRawShape>
// Documentation
description?: string
tags?: readonly string[]
metadata?: Record<string, unknown>
// Required; 'internal' excludes the route from generated OpenAPI docs
visibility: RouteVisibility
}Header schemas
const contract = defineApiContract({
visibility: 'public',
summary: 'Get data',
method: 'get',
pathResolver: () => '/api/data',
requestHeaderSchema: z.object({
authorization: z.string(),
'x-api-key': z.string(),
}),
responseHeaderSchema: z.object({
'x-ratelimit-remaining': z.string(),
'cache-control': z.string(),
}),
responsesByStatusCode: {
200: dataSchema,
},
})Type utilities
InferNonSseSuccessResponses<T> — TypeScript output type of all non-SSE 2xx responses. JSON schemas → z.output<T>, a blob entry → BlobResponseHandle, a no-body entry → undefined, an SSE entry → never (excluded). Content-map entries are unpacked before mapping.
import type { InferNonSseSuccessResponses } from '@lokalise/api-contracts'
type UserResponse = InferNonSseSuccessResponses<typeof getUser['responsesByStatusCode']>
// { id: string; name: string }
type CsvResponse = InferNonSseSuccessResponses<typeof exportCsv['responsesByStatusCode']>
// BlobResponseHandleInferJsonSuccessResponses<T> — union of Zod schema types for all JSON 2xx entries. Blob, SSE, and no-body entries are excluded.
InferSseSuccessResponses<T> — extracts the SSE event schema map type from a responsesByStatusCode map. Returns never when no SSE schemas are present.
HasAnySseSuccessResponse<T> — true if any 2xx entry (exact code or '2xx' range key) is an SSE response (a sseResponse() / content-map SSE descriptor).
HasAnyJsonSuccessResponse<T> — true if any 2xx entry is a JSON Zod schema or a content-map JSON descriptor.
HasAnyNonSseSuccessResponse<T> — true if any 2xx entry is a non-SSE response (JSON, blob, or no-body).
ContractResponseMode<T> — classifies a contract into 'dual' (SSE + non-SSE), 'sse' (SSE-only), or 'non-sse' (JSON/blob/no-body).
AvailableResponseModes<T> — union of mode literals available for a contract: 'json' | 'sse' | 'blob' | 'noContent'.
SseEventOf<S> — discriminated union of SSE events inferred from a schemaByEventName map. Aligns with the browser MessageEvent shape: { type, data, lastEventId, retry }.
import type { SseEventOf, InferSseSuccessResponses } from '@lokalise/api-contracts'
type NotificationEvents = InferSseSuccessResponses<typeof notifications['responsesByStatusCode']>
type NotificationEvent = SseEventOf<NotificationEvents>
// { type: 'notification'; data: { id: string; message: string }; lastEventId: string; retry: number | undefined }Client types
These types are primarily consumed by HTTP client implementations.
ClientRequestParams<TApiContract, TIsStreaming> — infers the request parameter object for a contract. Includes pathParams, body, queryParams, headers (required when the corresponding schema is defined), pathPrefix (always optional), and streaming (required for dual-mode contracts, forbidden otherwise).
InferSseClientResponse<TApiContract> — discriminated union of { statusCode, headers, body } for SSE mode. Exact 2xx codes and the '2xx' range key yield AsyncIterable<SseEventOf<...>>; error codes, other range keys, and 'default' yield the declared body type. 'default' is split into a SuccessfulHttpStatusCode half and a non-success half.
InferNonSseClientResponse<TApiContract> — same shape as above for non-SSE mode. Exact 2xx codes and the '2xx' range key yield JSON / BlobResponseHandle / null (SSE excluded); error codes, other range keys, and 'default' yield the declared body type as-is. 'default' is split the same way.
DefaultStreaming<T> — true for SSE-only contracts, false for everything else.
HeadersParam<T>, resolveHeadersParam(headers): request headers as a plain object or a sync/async factory, and the helper that resolves either form. Clients call it once per request so a refreshed token reaches a retry.
import type { ClientRequestParams, InferNonSseClientResponse } from '@lokalise/api-contracts'
type GetUserParams = ClientRequestParams<typeof getUser, false>
// { pathParams: { userId: string }; pathPrefix?: string }
type GetUserResponse = InferNonSseClientResponse<typeof getUser>
// { statusCode: 200; headers: Record<string, string>; body: { id: string; name: string } }Server types
The serving-side mirror of the client types, consumed by server adapters such as @lokalise/fastify-api-contracts. The schema direction is reversed: a server receives the validated request (z.output) and returns a response the serializer will still parse (z.input). Nothing here depends on a web framework.
InferServerRequest<TApiContract>: { pathParams, queryParams, headers, body } as the parsed output of each request schema, or undefined where the contract declares none. body is undefined for GET/DELETE and ContractNoBody contracts.
InferServerResponse<TApiContract, TBlobBody = unknown>: discriminated union of { status, contentType?, body } a handler may return. Wildcard status keys expand to the concrete statuses they cover, a content map with several media types requires contentType, a no-body entry requires body: null, and an SSE entry takes an AsyncIterable<SseStreamMessage<...>>. TBlobBody is what a handler returns for a blobBody() response; adapters narrow it to their runtime's raw body type.
InferServerResponseContentTypes<TApiContract>: union of the content types the success entries declare, i.e. the candidates a server may offer in Accept negotiation. A bare schema counts as application/json.
InferServerSseSelections<TApiContract>: every SSE representation as { statusCode, contentType, events }, one per sseBody() descriptor. Adapters use it to type which representation a handler starts streaming.
InferServerStatusesForKey<TApiContract, TKey>: the concrete statuses a responsesByStatusCode key stands for, following the runtime precedence of exact key, then range key, then 'default'.
SseMessage<T>, SseStreamMessage<Events>: one server-sent event as written to the stream, untyped or typed by an event-schema map.
import type { InferServerRequest, InferServerResponse } from '@lokalise/api-contracts'
type GetUserRequest = InferServerRequest<typeof getUser>
// { pathParams: { userId: string }; queryParams: undefined; headers: undefined; body: undefined }
type GetUserResult = InferServerResponse<typeof getUser>
// { status: 200; body: { id: string; name: string } }Contract type aliases
ApiContract — union of all contract variants (GetApiContract | DeleteApiContract | PayloadApiContract). Use this to type function parameters that accept any contract.
GetApiContract, DeleteApiContract, PayloadApiContract — individual contract variants if you need to narrow the type.
RequestPathParamsSchema, RequestQuerySchema, RequestHeaderSchema, ResponseHeaderSchema — type aliases for z.ZodObject. Use these to constrain schema arguments in generic helpers.
Utility functions
mapApiContractToPath — Express/Fastify-style path pattern.
import { mapApiContractToPath } from '@lokalise/api-contracts'
mapApiContractToPath(getUser) // "/users/:userId"describeApiContract — human-readable "METHOD /path" string.
import { describeApiContract } from '@lokalise/api-contracts'
describeApiContract(getUser) // "GET /users/:userId"hasAnySuccessSseResponse — true when any 2xx entry (exact code or '2xx' range key) is an SSE response (including inside a content map).
import { hasAnySuccessSseResponse } from '@lokalise/api-contracts'
hasAnySuccessSseResponse(notifications) // true
hasAnySuccessSseResponse(getUser) // false
hasAnySuccessSseResponse(chatCompletion) // true (dual-mode)getSseSchemaByEventName — extracts SSE event schemas from a contract. Returns null when no SSE schemas are present.
import { getSseSchemaByEventName } from '@lokalise/api-contracts'
getSseSchemaByEventName(notifications) // { notification: ZodObject<...> }
getSseSchemaByEventName(getUser) // nullModule augmentation
If you require more precise type definitions for the metadata field, you can utilize TypeScript's module augmentation mechanism to enforce stricter typing:
// file -> apiContracts.d.ts
import '@lokalise/api-contracts';
declare module '@lokalise/api-contracts' {
interface CommonRouteDefinitionMetadata {
myTestProp?: string[];
mySecondTestProp?: number;
}
}HTTP clients
To make contract-based requests, use a compatible HTTP client (@lokalise/frontend-http-client or @lokalise/backend-http-client).
For Fastify backends, use @lokalise/fastify-api-contracts to simplify route definition using contracts as the single source of truth.
Future: request body content type
Currently, HTTP clients default to application/json when a request body is present. The planned improvement is a requestBodyContentType field on defineApiContract:
defineApiContract({
visibility: 'public',
summary: 'Upload avatar',
method: 'post',
pathResolver: () => '/upload',
requestBodySchema: z.object({ file: z.unknown() }),
requestBodyContentType: 'multipart/form-data',
responsesByStatusCode: { 200: z.object({ url: z.string() }) },
})