OpenAPI + TypeScript Client
This recipe shows how to wire Contractual’s outputs section to orval so that every version bump regenerates the TypeScript client automatically. The result is a project where the client is consistent with the released contract version.
Project structure
Section titled “Project structure”my-api/├── contractual.yaml├── specs/│ └── orders.openapi.yaml # Source of truth├── src/│ └── generated/│ └── orders-client.ts # Auto-generated; do not edit by hand├── orval.config.ts└── .contractual/ ├── versions.json ├── changesets/ └── snapshots/ └── orders-api/ └── 1.0.0.yamlThe src/generated/ directory is fully managed by orval. Commit it to the repository so consumers can always import a working client even without running generation locally.
Step 1: Write the OpenAPI contract
Section titled “Step 1: Write the OpenAPI contract”Create specs/orders.openapi.yaml:
openapi: "3.1.0"info: title: Orders API version: "1.0.0"paths: /orders: get: operationId: listOrders summary: List orders parameters: - name: status in: query schema: type: string enum: [pending, confirmed, shipped, delivered] responses: "200": description: OK content: application/json: schema: type: array items: $ref: "#/components/schemas/Order" post: operationId: createOrder summary: Create an order requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateOrderInput" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{id}: get: operationId: getOrder summary: Get a single order parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/Order" "404": description: Not foundcomponents: schemas: Order: type: object required: [id, status, amount, createdAt] properties: id: type: string status: type: string enum: [pending, confirmed, shipped, delivered] amount: type: number description: Order total in the smallest currency unit (e.g. cents) createdAt: type: string format: date-time CreateOrderInput: type: object required: [amount] properties: amount: type: number notes: type: stringStep 2: Configure Contractual
Section titled “Step 2: Configure Contractual”Create contractual.yaml:
# yaml-language-server: $schema=https://contractual.dev/schema.json
contracts: - name: orders-api type: openapi path: ./specs/orders.openapi.yaml outputs: - name: typescript-client command: "orval --config orval.config.ts"The outputs command runs after every successful contractual lint. The {spec} placeholder is available if passing the path directly is preferred, but the orval config file handles that here.
Step 3: Configure orval
Section titled “Step 3: Configure orval”Create orval.config.ts:
import { defineConfig } from "orval";
export default defineConfig({ ordersApi: { input: { target: "./specs/orders.openapi.yaml", }, output: { target: "./src/generated/orders-client.ts", client: "fetch", // Generate a single file with types and functions together. // Use "split-tags" to split by tag instead. mode: "single", // Override the base URL at runtime via environment variable. baseUrl: process.env.ORDERS_API_BASE_URL ?? "http://localhost:3000", }, },});// Generated output uses the native fetch API with zero extra dependencies.import { listOrders } from "./src/generated/orders-client";
const orders = await listOrders({ status: "pending" });// Set client: "axios" in orval.config.ts.import { listOrders } from "./src/generated/orders-client";
const { data: orders } = await listOrders({ status: "pending" });// Set client: "react-query" in orval.config.ts.import { useListOrders } from "./src/generated/orders-client";
function OrderList() { const { data, isLoading } = useListOrders({ status: "pending" }); // ...}Step 4: Initialize and run
Section titled “Step 4: Initialize and run”-
Initialize Contractual. This creates the
.contractual/directory and takes the first snapshot.Terminal window contractual init -
Install orval.
Terminal window npm install --save-dev orval -
Run lint. This validates the spec and triggers the orval output command.
Terminal window contractual lintExpected output:
Linting contracts...✓ orders-api specs/orders.openapi.yamlRunning outputs...✓ typescript-client orval --config orval.config.tsAll contracts passed. -
Verify the generated file exists.
Terminal window ls src/generated/orders-client.ts
Step 5: Regenerate on version bump
Section titled “Step 5: Regenerate on version bump”Contractual runs outputs after every contractual lint. In CI, regeneration typically happens after the Version Contracts PR is merged, when the spec is at its new released version.
Add a job to the release workflow that runs lint after the version bump:
name: Contractual Release
on: push: branches: - main paths: - '.contractual/changesets/**'
permissions: contents: write pull-requests: write
jobs: contractual-release: name: Version contracts runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0
- name: Set up Node uses: actions/setup-node@v4 with: node-version: 20
- name: Install dependencies run: npm ci
- name: Run Contractual release id: release uses: contractual-dev/action@v1 with: mode: release github-token: ${{ secrets.GITHUB_TOKEN }}
# After the Version Contracts PR is merged, regenerate the client # at the new spec version and commit it back. - name: Regenerate TypeScript client if: steps.release.outputs.bumped-versions != '' run: npx contractual lint
- name: Commit regenerated client if: steps.release.outputs.bumped-versions != '' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add src/generated/ git diff --cached --quiet || git commit -m "chore: regenerate TypeScript client after contract version bump" git pushTriggering regeneration manually
Section titled “Triggering regeneration manually”To regenerate the client on demand without running the full workflow:
# Regenerate by running lint (outputs are a side effect of lint passing)contractual lint
# Or run orval directlynpx orval --config orval.config.tsHandling breaking changes in the client
Section titled “Handling breaking changes in the client”When contractual breaking detects a breaking change in the spec, the auto-generated changeset will carry a major bump. After the version is released the regenerated client will reflect the new types. Consumers of the client must update their call sites.
The breaking change workflow provides a window to communicate this: the PR comment diff table shows exactly which operations changed, and the CHANGELOG entry records the version at which the change landed.
Next steps
Section titled “Next steps”- Configuration:
outputssection reference and available placeholders - Enforcing No Breaking Changes: prevent accidental breaking changes from ever merging
- GitHub Action Setup: automate the full PR check and release workflow