⬇️ 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-typescript generates web-app/src/types/backend.d.ts from 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.response is always of type AsyncResourceResponse<MetricsGetAPI['Output'][]>.
  • updateMetric.call requires a MetricsPutAPI['Input'] and starts the async logic for returning a MetricsPutAPI['Output'] through updateMetric.response.data, which can be matched on via match(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.

9. References


⬇️ Download this article as Markdown