⬇️ Download this article as Markdown
Background: This article builds on the foundation described in Automatic TypeScript Generation from Backend Schema. If you haven’t read that, start there for the rationale and initial setup.
End-to-End TypeScript API Contracts: From Backend Schema to Frontend Usage
Overview
This article documents the complete, automated workflow that keeps our frontend TypeScript types in perfect sync with our backend API contract. We cover every step: from backend schema exposure, through Makefile automation and type generation, to ergonomic, type-safe API usage in React components.
1. Backend: Exposing the OpenAPI Schema
Our Go backend exposes an OpenAPI schema at / (or /openapi.json). This schema is generated automatically from registered routes and Go types, ensuring all request/response contracts are captured. See the previous article for details on how pointers, time.Time, and required/optional fields are handled.
2. Makefile Automation: Generating Types
The Makefile orchestrates the entire type generation process:
run-backend: ensure-db ensure-mock-ai
# ...
@curl -s http://localhost:8080/ > schema.json
schema.json: run-backend
generate-types: schema.json
@mkdir -p web-app/src/types
@npx openapi-typescript schema.json --output web-app/src/types/backend.d.ts
@cd web-app && node scripts/generate-api-contracts.js
- The backend is started and the OpenAPI schema is fetched to
schema.json. openapi-typescriptgeneratesweb-app/src/types/backend.d.tsfrom the schema.- A custom Node.js script generates API contract types in
web-app/src/types/index.d.ts.
3. TypeScript Type Generation
openapi-typescript produces a components namespace in backend.d.ts:
// web-app/src/types/backend.d.ts
export namespace components {
export namespace schemas {
export interface MetricResponse { /* ... */ }
export interface MetricInput { /* ... */ }
// ...
}
}
4. API Contract Type Generation
The script web-app/scripts/generate-api-contracts.js reads the OpenAPI schema and emits strongly-typed API contract types for every route:
// web-app/src/types/index.d.ts
import type { components } from './backend';
import type { APIContract } from './contract';
export type MetricsGetAPI = APIContract<never, components['schemas']['MetricResponse'][], '/metrics', 'get'>;
export type MetricsPutAPI = APIContract<components['schemas']['MetricUpdateInput'], components['schemas']['MetricResponse'], '/metrics', 'put'>;
// ...
Each type encodes the input, output, path, and method for a specific API route.
5. The APIContract Type
All API contract types are based on a generic utility:
// web-app/src/types/contract.d.ts
export type APIContract<Input, Output, Path extends string, Method extends string = 'get' | 'post' | 'put'> = {
Input: Input;
Output: Output;
path: Path;
method: Method;
};
This enables precise typing for every endpoint.
6. The useApiContract Hook
Our custom React hook provides a type-safe, ergonomic way to call any API route using its contract type:
// web-app/src/App/hooks/useApiContract.ts
export function useApiContract<Contract extends APIContract<any, any, string, 'get'>>(
args: { path: Contract['path']; method: 'get' } & UseAsyncResourceOptions
): { response: AsyncResourceResponse<Contract['Output']>; call: () => void };
export function useApiContract<Contract extends APIContract<any, any, string, 'post'>>(
args: { path: Contract['path']; method: 'post' } & UseAsyncResourceOptions
): { response: AsyncResourceResponse<Contract['Output']>; call: (input: Contract['Input']) => void };
// ...implementation...
The hook enforces correct input/output types for each method and path.
7. Real Usage: MetricsPage Example
In a real component, usage is simple and fully type-safe:
// web-app/src/App/MetricsPage/index.tsx
const allMetrics = useApiContract<MetricsGetAPI>({
path: '/metrics',
method: 'get',
immediate: true,
});
const updateMetric = useApiContract<MetricsPutAPI>({
path: '/metrics',
method: 'put',
});
allMetrics.responseis always of typeAsyncResourceResponse<MetricsGetAPI['Output'][]>.updateMetric.callrequires aMetricsPutAPI['Input']and starts the async logic for returning aMetricsPutAPI['Output']throughupdateMetric.response.data, which can be matched on viamatch(updateMetric.response).with(...).
8. Benefits: Safe, Type-Driven API Usage
- No manual sync: Types are always up-to-date with the backend.
- Type errors are caught at compile time.
- Ergonomic developer experience: API usage is as simple as calling a hook with the right contract type.
- Automatic contract validation: The dev workflow fails fast if there is a mismatch.