When a project exposes a REST API, every consumer—web dashboard, CLI tool, mobile app, integration test suite—needs to know the exact shape of requests and responses. Manually writing client code for each consumer is tedious, error-prone, and falls out of sync the moment someone adds a field to the API without updating every client. OpenAPI code generation eliminates this problem by treating the OpenAPI 3.0 specification as the single source of truth and auto-generating type-safe client SDKs, server stubs, request validation, and documentation from it.
A polyglot project can use this approach across multiple languages. The Go backend uses oapi-codegen to generate server interfaces and request/response types from the OpenAPI spec, ensuring that the implementation stays in sync with the documented contract. The TypeScript frontend uses openapi-typescript to generate TypeScript types and openapi-fetch to create a fully-typed HTTP client that provides autocomplete for every endpoint, path parameter, query parameter, and response shape. Changes to the API spec automatically propagate to all consumers at compile time.
This document covers the OpenAPI specification format, code generation toolchains for Go and TypeScript, patterns for keeping generated code in sync with handwritten business logic, and strategies for versioning and backward compatibility.
An OpenAPI spec is a YAML or JSON document that describes every endpoint, parameter, request body, and response schema of a REST API. It uses JSON Schema for type definitions and supports composition via $ref, allOf, oneOf, and anyOf. The spec serves as both human-readable documentation and machine-readable contract: tools can generate code, validate requests, produce documentation sites, and run contract tests from the same file.
oapi-codegen reads an OpenAPI spec and emits Go interfaces, request/response structs, and routing code for popular frameworks (Chi, Echo, Gin, net/http). The generated server interface has one method per operation, and each method receives a typed parameters struct and returns a typed response. The developer implements the interface with business logic; the generated routing layer handles parameter parsing, validation, and response serialization. If the spec changes and the implementation no longer satisfies the interface, the Go compiler catches the mismatch.
openapi-typescript converts the OpenAPI spec into TypeScript type definitions—one type per schema, plus path/operation types that map endpoint URLs to their parameters and responses. openapi-fetch then uses these types to provide a fetch wrapper where client.GET("/collections/{id}", { params: { path: { id } } }) is fully typed: the path, query params, request body, and response body all have compile-time type checking and IDE autocomplete, with zero runtime overhead beyond a standard fetch call.
Large APIs define shared schemas in components/schemas and reference them with $ref: "#/components/schemas/Collection". This enables reuse (the same Error schema across all error responses), modularity (split the spec into multiple files), and cleaner generated code (shared types instead of duplicated inline definitions). Code generators resolve $ref pointers and emit named types, producing a clean type hierarchy that mirrors the API’s domain model.
In contract-first development, the OpenAPI spec is written before the implementation, and code is generated from it. In code-first development, the implementation comes first, and the spec is generated from code annotations. The contract-first approach treats the spec as the authoritative artifact, and both server and client code are derived from it. This ensures that the API contract is a deliberate design decision rather than an accident of implementation.
The API spec defines endpoints, parameters, request/response schemas, and authentication requirements. Here is a simplified example of a collection management API spec:
openapi: "3.0.3"
info:
title: ExampleDB API
version: "1.0.0"
paths:
/collections/{collection_name}:
get:
operationId: getCollection
summary: Get collection details
parameters:
- name: collection_name
in: path
required: true
schema:
type: string
responses:
"200":
description: Collection details
content:
application/json:
schema:
$ref: "#/components/schemas/CollectionInfo"
"404":
description: Collection not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
put:
operationId: createCollection
summary: Create or update a collection
parameters:
- name: collection_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateCollectionRequest"
responses:
"200":
description: Collection created
content:
application/json:
schema:
$ref: "#/components/schemas/CollectionInfo"
components:
schemas:
CollectionInfo:
type: object
required: [name, vectors_count, status]
properties:
name:
type: string
vectors_count:
type: integer
format: int64
status:
type: string
enum: [green, yellow, red]
CreateCollectionRequest:
type: object
required: [vectors]
properties:
vectors:
$ref: "#/components/schemas/VectorsConfig"
VectorsConfig:
type: object
required: [size, distance]
properties:
size:
type: integer
distance:
type: string
enum: [Cosine, Euclid, Dot]
Error:
type: object
required: [status, message]
properties:
status:
type: string
message:
type: string
The oapi-codegen tool generates Go types and a server interface from the spec. The generated interface enforces that the implementation handles every endpoint with the correct parameter and response types:
// Generated by oapi-codegen from the OpenAPI spec
//go:generate oapi-codegen --config=oapi-config.yaml api.yaml
type CollectionInfo struct {
Name string `json:"name"`
VectorsCount int64 `json:"vectors_count"`
Status string `json:"status"`
}
type CreateCollectionRequest struct {
Vectors VectorsConfig `json:"vectors"`
}
type VectorsConfig struct {
Size int `json:"size"`
Distance string `json:"distance"`
}
type ServerInterface interface {
GetCollection(w http.ResponseWriter, r *http.Request, collectionName string)
CreateCollection(w http.ResponseWriter, r *http.Request, collectionName string)
}
// Implementation satisfies the generated interface
type APIHandler struct {
store *CollectionStore
}
func (h *APIHandler) GetCollection(w http.ResponseWriter, r *http.Request, collectionName string) {
coll, err := h.store.Get(r.Context(), collectionName)
if err != nil {
writeError(w, http.StatusNotFound, "collection not found")
return
}
writeJSON(w, http.StatusOK, CollectionInfo{
Name: coll.Name,
VectorsCount: coll.VectorsCount,
Status: coll.Status.String(),
})
}
func (h *APIHandler) CreateCollection(w http.ResponseWriter, r *http.Request, collectionName string) {
var req CreateCollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
coll, err := h.store.Create(r.Context(), collectionName, req.Vectors)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, coll.ToInfo())
}
On the frontend, openapi-typescript generates types, and openapi-fetch creates a typed client. The result is a fully type-safe API client with zero manual type definitions:
// Generated types from openapi-typescript
import type { paths } from "./generated/api";
import createClient from "openapi-fetch";
const client = createClient<paths>({
baseUrl: "https://api.exampledb.dev",
headers: { Authorization: `Bearer ${token}` },
});
// Fully typed: path params, response body, error shape
const { data, error } = await client.GET("/collections/{collection_name}", {
params: { path: { collection_name: "embeddings" } },
});
if (error) {
// error is typed as { status: string; message: string }
console.error(error.message);
} else {
// data is typed as CollectionInfo
console.log(data.name, data.vectors_count, data.status);
}
// Create a collection - request body is type-checked
const { data: newColl } = await client.PUT("/collections/{collection_name}", {
params: { path: { collection_name: "new-index" } },
body: {
vectors: { size: 768, distance: "Cosine" },
},
});
Code generation runs as a build step, typically via go generate in Go and an npm script in TypeScript. The CI pipeline regenerates code from the spec and fails if the output differs from what is checked in, ensuring that generated code is always up to date:
# Makefile target for Go code generation
.PHONY: generate
generate:
oapi-codegen --config=oapi-config.yaml api/openapi.yaml > internal/api/generated.go
# package.json script for TypeScript
# "scripts": {
# "generate:api": "openapi-typescript api/openapi.yaml -o src/generated/api.ts",
# "ci:check-generated": "npm run generate:api && git diff --exit-code src/generated/"
# }
In a polyglot service, OpenAPI code generation plays a central role in maintaining consistency across the codebase:
The recommended workflow is: (1) edit the OpenAPI spec, (2) run code generation, (3) fix compile errors in the implementation, (4) write tests, (5) commit. This ensures the spec drives the implementation rather than the other way around. PR reviews start by reviewing the spec diff, which is more readable than code changes.
When a spec change removes a field or changes a type, all generated clients break at compile time. This is a feature: it forces the team to coordinate breaking changes. For gradual rollouts, use API versioning (path-based like /v2/collections or header-based) and generate separate client types for each version.
OpenAPI supports vendor extensions (x- prefixed fields) for tool-specific configuration. For example, a Go project can use x-go-type to map OpenAPI types to existing Go types and x-oapi-codegen-extra-tags to add struct tags like validate:"required" to generated fields.
// DO NOT EDIT headers and CI checks to enforce this.allOf/oneOf compositions can produce confusing generated types. Flatten schemas where possible and use $ref to named components for clarity.nullable: true to indicate optional fields that can be null. Code generators handle this differently—oapi-codegen uses pointer types in Go, openapi-typescript uses union with null. Be explicit about nullability to avoid runtime surprises.$ref to external files and use tools like @redocly/cli bundle to merge them for code generation.