TypeSpec: API Design Before Code
Most teams design APIs one of two ways: write code first and extract OpenAPI from annotations, or write OpenAPI YAML directly and generate types from it. Both have the same problem — you end up maintaining two things (schema + code) that need to stay in sync.
Photo by DFY® 디에프와이 on Unsplash
TypeSpec is Microsoft's answer: a dedicated language for expressing API shapes, from which you generate OpenAPI, JSON Schema, Protobuf, client SDKs, and server scaffolding. One source of truth, multiple outputs.
Why a Dedicated API Language
OpenAPI YAML gets verbose fast. A simple CRUD endpoint with proper schemas, error responses, and pagination can run to hundreds of lines. Maintaining it by hand is error-prone; generating it from code annotations couples your API contract to your implementation choices.
TypeSpec treats API design as a first-class programming task:
import "@typespec/http";
using TypeSpec.Http;
model User {
id: string;
email: string;
name: string;
createdAt: utcDateTime;
}
@route("/users")
interface Users {
@get list(): User[];
@post create(@body user: OmitProperties<User, "id" | "createdAt">): User;
@get read(@path id: string): User | NotFoundResponse;
@patch update(@path id: string, @body patch: UpdateableProperties<User>): User;
@delete remove(@path id: string): void;
}
This generates a complete OpenAPI 3.0 spec, including proper response schemas and HTTP status codes.
Installation
npm install -g @typespec/compiler
# Initialize a new TypeSpec project
mkdir my-api && cd my-api
tsp init --template rest
# Or add to existing project
npm install --save-dev @typespec/compiler @typespec/http @typespec/rest @typespec/openapi3
Project structure:
my-api/
├── main.tsp # Main TypeSpec file
├── tspconfig.yaml # Compiler configuration
└── package.json
Basic TypeSpec Syntax
Models (Schemas)
// Simple model
model Product {
id: string;
name: string;
price: float64;
category: string;
inStock: boolean;
}
// Extend a model
model DigitalProduct extends Product {
downloadUrl: string;
fileSize: int64;
}
// Template models
model Page<T> {
items: T[];
total: int64;
page: int32;
pageSize: int32;
}
// Utility types
model CreateProductRequest is OmitProperties<Product, "id">;
model UpdateProductRequest is UpdateableProperties<Product>;
Enums and Unions
enum OrderStatus {
Pending: "pending",
Processing: "processing",
Shipped: "shipped",
Delivered: "delivered",
Cancelled: "cancelled",
}
// Union types
alias StringOrNumber = string | int32;
alias Response<T> = T | NotFoundResponse | UnauthorizedResponse;
Decorators
TypeSpec uses decorators to add HTTP semantics, validation, and documentation:
model User {
@format("email")
email: string;
@minLength(8)
@maxLength(72)
password: string;
@minValue(0)
@maxValue(120)
age?: int32;
@doc("ISO 3166-1 alpha-2 country code")
country?: string;
}
HTTP Operations
import "@typespec/http";
using TypeSpec.Http;
@service({
title: "Product API",
version: "1.0.0",
})
@server("https://api.example.com", "Production")
namespace ProductAPI;
@route("/products")
@tag("Products")
interface ProductOperations {
@doc("List all products with optional filtering")
@get
list(
@query category?: string,
@query page?: int32 = 1,
@query pageSize?: int32 = 20,
): Page<Product>;
@doc("Create a new product")
@post
create(@body product: CreateProductRequest): {
@statusCode statusCode: 201;
@body product: Product;
};
@doc("Get a specific product")
@get
read(@path id: string): Product | NotFoundResponse;
}
Authentication
import "@typespec/http";
using TypeSpec.Http;
// Define security scheme
@useAuth(BearerAuth)
namespace SecureAPI;
// Or per-operation
@useAuth(BearerAuth | ApiKeyAuth<ApiKeyLocation.header, "X-API-Key">)
interface AdminOperations {
@get
dashboard(): DashboardData;
}
Generating Output
# Compile to OpenAPI 3.0
tsp compile main.tsp
# Output goes to tsp-output/ by default
ls tsp-output/@typespec/openapi3/
# openapi.yaml
Configure output in tspconfig.yaml:
emit:
- "@typespec/openapi3"
- "@typespec/json-schema"
options:
"@typespec/openapi3":
output-file: "openapi.yaml"
openapi-versions:
- "3.0.0"
"@typespec/json-schema":
output-dir: "json-schemas"
Generating Clients
With the generated OpenAPI spec, use any OpenAPI client generator:
# TypeScript client with openapi-typescript
npx openapi-typescript tsp-output/@typespec/openapi3/openapi.yaml -o src/api.ts
# Python client
openapi-generator-cli generate \
-i tsp-output/@typespec/openapi3/openapi.yaml \
-g python \
-o clients/python
# Go client
openapi-generator-cli generate \
-i tsp-output/@typespec/openapi3/openapi.yaml \
-g go \
-o clients/go
CI Integration
Check that generated specs don't drift from the source:
# .github/workflows/api-check.yml
name: API Spec Check
on: [push, pull_request]
jobs:
check-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tsp compile main.tsp
- name: Check for spec drift
run: |
if ! git diff --quiet tsp-output/; then
echo "Generated spec has changed. Run 'tsp compile' and commit."
git diff tsp-output/
exit 1
fi
TypeSpec vs. Alternatives
| Tool | Approach | Output |
|---|---|---|
| TypeSpec | Design-first language | OpenAPI, Protobuf, JSON Schema |
| OpenAPI YAML | Write spec directly | Docs, clients |
| tRPC | Code-first (TypeScript) | TypeScript clients only |
| Protobuf | IDL for binary protocols | gRPC, binary |
| Zod + openapi-zod-client | Code-first validation | OpenAPI from Zod schemas |
TypeSpec fills the gap between "write OpenAPI YAML" (verbose, no reuse) and "code-first with annotations" (couples spec to implementation). It's most valuable for teams with multiple consumers of an API, or where the contract needs to exist independently of any implementation.
