One schema to rule them all: generating OpenAPI 3.1, gRPC Protobuf, and TypeScript clients simultaneously — A practical guide to TypeSpec 1.0
This happened to me last year. The payments team renamed the settled_at field to completedAt in the /payments/{id} response. There was a JIRA ticket, code review passed, and an announcement went out on the company Slack. But the order team's BFF was still reading settled_at, and the two teams were each calling a different API spec document the "latest version." CI was happily green, but on Monday morning a undefined error quietly blew up in production.
This is Contract Drift — the phenomenon where actual implementation code and documented API contracts gradually diverge over time, and it becomes more frequent as team boundaries multiply in a microservices environment. Even adopting Swagger codegen won't prevent drift without team-wide agreement on "spec-first vs. code-first."
TypeSpec structurally blocks this problem at the tooling level. A single .tsp file becomes the Single Source of Truth (SSOT) that simultaneously generates an OpenAPI 3.1 spec, gRPC Protobuf definitions, and TypeScript client code. TypeSpec 1.0 GA shipped in May 2025, and Microsoft Azure is actively running a pipeline that manages hundreds of service APIs with TypeSpec and auto-generates SDKs.
In this article, we'll look at how TypeSpec works, its core syntax, the practical workflow for extracting multi-protocol artifacts from a single schema, and the limitations you should know about right now.
Core Concepts
How TypeSpec Differs from Existing Approaches
TypeSpec is an open-source API definition language (IDL) developed by Microsoft. You declare your API shape in TypeScript-inspired syntax, and plugins called Emitters produce the artifacts you want — OpenAPI YAML, .proto files, client code, and more. (Its previous name was "Cadl," so if you search for "cadl" on Stack Overflow or older blog posts, you'll find older materials for the same project. The syntax changed, which can cause confusion, so it's safest to rely on the official documentation.)
Here's how it compares to existing approaches:
| Approach | Flow | Problem |
|---|---|---|
| Code-First | Write code → annotations → generate spec | Implementation and spec easily fall out of sync |
| OpenAPI-First | Write spec YAML → codegen | YAML is verbose, style inconsistencies across teams |
| TypeSpec-First | .tsp declaration → emitters → all artifacts |
SSOT is clear, simultaneous multi-protocol support |
Emitter Architecture at a Glance
Here's how TypeSpec generates multiple artifacts simultaneously from a single source:
flowchart TD
A[.tsp File Single Source] --> B[TypeSpec Compiler]
B --> C[OpenAPI 3.1 Emitter]
B --> D[Protobuf Emitter]
B --> E[TypeScript Client Emitter]
C --> F[openapi.yaml]
D --> G[service.proto]
E --> H[TypeScript SDK]
F --> I[Swagger UI / API Gateway]
G --> J[gRPC Server Stub]
G --> K[gRPC Client Stub]
H --> L[Frontend / BFF]Here's the current status of the core packages:
| Package | Role | Status |
|---|---|---|
@typespec/compiler |
Compiler core | Stable 1.x |
@typespec/http |
HTTP/REST decorators | Stable 1.x |
@typespec/openapi3 |
OpenAPI 3.0/3.1 emitter | Stable 1.x |
@typespec/protobuf |
Protobuf/gRPC emitter | Preview |
@typespec/http-client-js |
TypeScript client emitter | Preview |
@typespec/json-schema |
JSON Schema emitter | Stable |
On Preview emitters — a one-time note:
@typespec/protobufand@typespec/http-client-jsremain in Preview even after TypeSpec 1.0 GA. API stability is not guaranteed, and generated output may change between version updates. Keep this in mind when reading the sections below that cover these two emitters. Thorough validation is required before production use, and package version numbers may differ from what's shown here — check npm directly for the most accurate information.
A Quick Syntax Overview
If you're familiar with TypeScript, a .tsp file will feel natural from the start.
import "@typespec/http";
using TypeSpec.Http;
// Model definition
model Product {
id: string;
name: string;
priceInCents: int32; // Integer in cents to avoid floating-point precision issues
category: "electronics" | "clothing" | "food";
}
model ProductPage {
items: Product[];
total: int32;
page: int32;
}
// Error model — without @statusCode, the OpenAPI spec maps the response code to `default`
@error
model NotFound {
@statusCode statusCode: 404;
code: "NOT_FOUND";
message: string;
}
// Route definition
@route("/products")
interface Products {
@get list(@query page?: int32, @query limit?: int32): ProductPage;
@get read(@path id: string): Product | NotFound;
@post create(@body product: Omit<Product, "id">): Product;
@put update(@path id: string, @body product: Product): Product | NotFound;
@delete remove(@path id: string): void | NotFound;
}Decorators like @route, @get, @post, @body, @path, and @query provide HTTP semantics. You need to declare both @error and @statusCode together for the OpenAPI emitter to correctly map the 404 status code. Union types and utility types like Omit<> work exactly as they do in TypeScript.
Running All Emitters at Once with tspconfig.yaml
# tspconfig.yaml
emit:
- "@typespec/openapi3"
- "@typespec/protobuf"
- "@typespec/http-client-js"
options:
"@typespec/openapi3":
output-file: "dist/openapi.yaml"
openapi-versions:
- "3.1.0"
"@typespec/protobuf":
output-dir: "dist/proto"
"@typespec/http-client-js":
output-dir: "dist/client"List your emitters in this single file, and tsp compile . generates all artifacts at once.
Quick Start
Before diving into production code, set up your local environment first.
# Install the VS Code TypeSpec extension for syntax highlighting and autocomplete
# Search "TypeSpec" on marketplace.visualstudio.com
# Initialize a new TypeSpec project
npm init -y
npm install -D @typespec/compiler @typespec/http @typespec/openapi3
# Scaffold an empty project (auto-generates tspconfig.yaml, main.tsp, etc.)
npx tsp init
# Run the compiler — generates artifacts under dist/
npx tsp compile .The fastest way to get a feel for it is to migrate one existing REST API to TypeSpec and compare the emitter output against your current spec.
Practical Application
Scenario: Order Service Microservice
You have an OrderService that needs to simultaneously support external REST clients (mobile apps, partner APIs) and internal gRPC services (payments, inventory).
First, define the shared models. Declare the models used by both REST and gRPC in a single file, and extract the repeated Omit expression into CreateOrderRequest.
// models/order.tsp
import "@typespec/http";
import "@typespec/protobuf";
using TypeSpec.Http;
using TypeSpec.Protobuf;
@package
namespace OrderService;
enum OrderStatus {
Pending,
Fulfilled,
Cancelled,
}
model Order {
@field(1) id: string;
@field(2) customerId: string;
@field(3) amountInCents: int32; // Integer in cents to avoid floating-point precision issues
@field(4) status: OrderStatus;
@field(5) createdAt: utcDateTime;
}
@error
model OrderNotFound {
@statusCode statusCode: 404;
code: "ORDER_NOT_FOUND";
orderId: string;
}
// Explicitly extract the creation request model to avoid repeating Omit
model CreateOrderRequest {
customerId: string;
amountInCents: int32;
}Define the REST interface in a separate file. The HTTP emitter processes interfaces decorated with @route and @service to generate openapi.yaml.
// services/orders-rest.tsp
import "../models/order.tsp";
using TypeSpec.Http;
using OrderService;
@service
@route("/orders")
interface Orders {
@get list(@query status?: OrderStatus): Order[];
@get read(@path id: string): Order | OrderNotFound;
@post create(@body order: CreateOrderRequest): Order;
}Separate the gRPC service into its own file as well. The Protobuf emitter generates the .proto file based on the @package namespace and @field decorators.
// services/orders-grpc.tsp
import "../models/order.tsp";
using TypeSpec.Protobuf;
using OrderService;
@service
interface OrderGrpcService {
getOrder(req: { id: string }): Order;
listOrders(req: { status?: OrderStatus }): { orders: Order[] };
createOrder(req: CreateOrderRequest): Order;
}Because the two emitters operate on different decorator systems, REST and gRPC interfaces can coexist from the same source. Since the Protobuf emitter is in Preview, always verify that the .proto file generated by tsp compile . looks as intended.
Integrating with a CI Pipeline
The key to structurally preventing drift is tying spec generation to CI. Here are two supporting tools the pipeline uses:
- Spectral: A linting tool for OpenAPI specs. Define custom rules for naming conventions, missing required fields, security schemas, and more to automatically validate spec quality.
- oasdiff: Compares two OpenAPI specs and detects breaking changes (deleted fields, type changes, etc.). Compare the
mainbranch spec against a PR spec to block unintended backwards-incompatible changes at the PR stage.
# .github/workflows/typespec.yml
name: TypeSpec Compile & Lint
on:
push:
paths:
- "api/**/*.tsp"
- "tspconfig.yaml"
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Compile TypeSpec
run: npx tsp compile .
# Spectral: OpenAPI spec quality linting
- name: Lint OpenAPI spec
run: npx spectral lint dist/openapi.yaml
# oasdiff: breaking change detection against the main branch
- name: Breaking change detection
run: |
npx oasdiff breaking \
https://raw.githubusercontent.com/org/repo/main/dist/openapi.yaml \
dist/openapi.yaml \
--fail-on ERR
# Optional: auto-commit generated artifacts to the repository
# Uploading as GitHub Actions artifacts is also a valid alternative
- name: Commit generated artifacts
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: regenerate API artifacts from TypeSpec"
file_pattern: "dist/**"Here's the full flow of how this pipeline prevents drift:
Choosing a generated artifact commit strategy: Committing generated files like dist/openapi.yaml to the repository lets you review spec change diffs directly in PRs and makes it easy to guarantee spec freshness. However, in environments where files are large or multiple teams are opening PRs simultaneously, merge conflicts become frequent and diff noise increases. In that case, uploading as GitHub Actions artifacts or publishing to an external spec registry are equally valid alternatives. Whichever strategy you choose, the important thing is that the entire team follows it consistently afterward.
Using the TypeScript Client
Here's how you can use the client generated by @typespec/http-client-js (Preview) in your frontend or BFF:
// Import the auto-generated file from the emitter
// The actual path and class name may vary by emitter version — check the generated output directly
import { OrdersClient } from "../dist/client";
const client = new OrdersClient({ endpoint: "https://api.example.com" });
// Types are fully inferred
const orders = await client.orders.list({ status: "Pending" });
orders.forEach((order) => {
console.log(order.amountInCents); // inferred as number type
});If you change the type of Order.amountInCents on the backend from int32 to string, this client code will error immediately at the next compile time — not at runtime. This guarantee holds because the .tsp file serves as the SSOT.
Shared Model Library Across Teams
If you have multiple microservices, you can separate common models into an npm package for sharing.
// packages/common-types/main.tsp
namespace Common;
model PaginationParams {
@query page?: int32 = 1;
@query limit?: int32 = 20;
}
model PagedResponse<T> {
items: T[];
total: int32;
page: int32;
hasNext: boolean;
}
@error
model ApiError {
@statusCode statusCode: int32;
code: string;
message: string;
}// services/order-service/main.tsp
import "@company/common-types";
using Common;
model OrderPage is PagedResponse<Order>;
@route("/orders")
interface Orders {
@get list(...PaginationParams): OrderPage;
}This lets you enforce team governance at the shared library level, structurally preventing the problem of error formats diverging across services.
Adoption Decision and Trade-offs
First: Is TypeSpec the Right Fit for Your Team?
Before looking at the pros and cons, it's practical to first check whether TypeSpec is the right choice for your team's situation.
Even teams that say "our spec automation is already working well" will find TypeSpec worthwhile if they're introducing gRPC or need multi-protocol support. On the other hand, if a small REST-only team has an existing OpenAPI pipeline running smoothly, the migration cost may outweigh the benefit.
Trade-offs at a Glance
| Item | Assessment |
|---|---|
| Single SSOT | Structurally blocks contract drift. TypeSpec's greatest strength right now |
| TypeScript-friendly syntax | Low learning curve for TS developers. Union types, generics, and utility types work the same way |
| Reusable libraries | Package common models as npm packages for consistent governance across teams |
| CI integration | One line: tsp compile .. Combined with Spectral and oasdiff, drift detection is complete |
| OpenAPI emitter stability | 1.x Stable. Works as-is with existing Swagger UI, Redoc, Postman, and APIM |
| Protobuf emitter maturity | Preview status. Limitations with complex streaming patterns, etc. — see warning box above |
| Client code generation | .NET, Java, Python, JS are all Preview — see warning box above |
| Ecosystem size | Fewer third-party plugins and community resources compared to OpenAPI |
| Vendor dependency | Microsoft-led language. Teams that prioritize tool autonomy should consider this |
| Migration cost | Teams coming from Code-First or manual YAML require initial learning and pipeline reconfiguration |
Common Mistakes in Practice
1. Shipping Preview emitters directly to production
Some teams overlook the fact that the Protobuf emitter and client emitters are in Preview and connect them straight to gRPC services. Since generated output can change with version updates, either validate thoroughly or use the .proto output only as a reference rather than a direct dependency.
2. Unconditionally adding generated artifacts to .gitignore
If you ignore generated files, you lose the ability to verify spec freshness in CI. Whichever strategy you choose — committing artifacts or uploading them — the important thing is that the entire team follows it consistently.
3. Letting model name collisions go unresolved without namespaces
If you don't declare a namespace across multiple .tsp files, models with the same name will collide. Establish your namespace strategy before you start adding files.
4. Managing @typespec/* package versions inconsistently
Get into the habit of keeping all @typespec/* packages pinned to the same version in package.json and checking release notes on minor updates. Before 1.0, the language was called "Cadl" and its syntax changed several times — a precedent worth keeping in mind.
Closing Thoughts
The key to solving contract drift is a structure that enforces a single source. Teams that only use REST may be fine with their existing OpenAPI pipeline, but if you need to support both gRPC and REST simultaneously, or manage a shared contract consumed by multiple teams, TypeSpec is currently the most structurally sound solution.
To summarize:
- A single
.tspfile becomes the SSOT for OpenAPI, Protobuf, and client code - Tying
tsp compile .to CI automatically guarantees spec freshness - The OpenAPI emitter is Stable; the Protobuf and client emitters are Preview — recognize this distinction and scope your adoption accordingly
If you want to get started now, this order is natural:
- Run
tsp initto create an empty project, then migrate one existing REST API to TypeSpec — compare the OpenAPI emitter output against your current spec. - Connect
tsp compile .to GitHub Actions — add Spectral linting and oasdiff breaking change detection to complete the drift detection pipeline. - Validate on one pilot service before expanding to the whole team — rather than migrating the entire organization at once, it's more realistic to learn the workflow on one new service and then expand incrementally.
References
- TypeSpec 1.0 GA: API First, Made Practical — Official Blog
- TypeSpec 1.0-RC Release Notes
- TypeSpec Official Documentation
- Overview of TypeSpec — Microsoft Learn
- TypeSpec Protobuf Emitter Guide
- TypeSpec OpenAPI v3 Emitter Reference
- TypeSpec Configuration (tspconfig.yaml)
- GitHub — microsoft/typespec
- A Technical Journey into API Design-First — Microsoft ISE Developer Blog
- API-first MCP servers with TypeSpec — Official Blog
- OpenAPI vs. TypeSpec: Which To Use? — Nordic APIs
- TypeSpec: A Practical TypeScript-Inspired API Definition Language — InfoQ
- How to Use TypeSpec for Documenting and Modeling APIs — freeCodeCamp
- How to create OpenAPI and SDKs with TypeSpec — Speakeasy
- What is API drift and how do you prevent it? — Wiz
- Accelerating your OpenAPI Spec Generation with TypeSpec — Bump.sh
- @typespec/openapi3 — npm
- @typespec/protobuf — npm
- @typespec/http-client-js — npm
- TypeSpec for OpenAPI Developers
- TypeSpec 1.1.0 Release Notes
- Azure TypeSpec Style Guide