Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
Search posts
Backend

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:

mermaid
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/protobuf and @typespec/http-client-js remain 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.

typespec
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

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.

bash
# 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.

typespec
// 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.

typespec
// 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.

typespec
// 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 main branch spec against a PR spec to block unintended backwards-incompatible changes at the PR stage.
yaml
# .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:

Diagram 2

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:

typescript
// 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.

typespec
// 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;
}
typespec
// 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.

Diagram 3

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 .tsp file 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:

  1. Run tsp init to create an empty project, then migrate one existing REST API to TypeSpec — compare the OpenAPI emitter output against your current spec.
  2. Connect tsp compile . to GitHub Actions — add Spectral linting and oasdiff breaking change detection to complete the drift detection pipeline.
  3. 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
#TypeSpec#OpenAPI#Protobuf#gRPC#TypeScript#API Design
Share

Table of Contents

Core ConceptsHow TypeSpec Differs from Existing ApproachesEmitter Architecture at a GlanceA Quick Syntax OverviewRunning All Emitters at Once with tspconfig.yamlQuick StartPractical ApplicationScenario: Order Service MicroserviceIntegrating with a CI PipelineUsing the TypeScript ClientShared Model Library Across TeamsAdoption Decision and Trade-offsFirst: Is TypeSpec the Right Fit for Your Team?Trade-offs at a GlanceCommon Mistakes in PracticeClosing ThoughtsReferences

Recommended Posts

Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`
Backend

Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`

Supply chain attacks in the npm ecosystem are on the rise. The moment a single package is compromised, it becomes hard to fully trust any of the hundr…

July 20, 202614 min read
Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system
Backend

Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system

Translating the document now, following the advisor's guidance on participant IDs in sequence diagrams and all other rules. Feeling honestly nervous w…

July 19, 202627 min read
Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern
Backend

Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern

The NOT VALID + VALIDATE CONSTRAINT combination is especially powerful in PostgreSQL 12+. Because VALIDATE CONSTRAINT only acquires a SHARE UPDATE EXC…

July 19, 202611 min read
Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients
Backend

Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients

Every time you start a new project, you type npm install pg ioredis @aws sdk/client s3 , wrestle with @types/ packages, chase down version conflicts,…

July 19, 202620 min read
Building a TypeScript REST API with Fastify v5 + TypeBox
Backend

Building a TypeScript REST API with Fastify v5 + TypeBox

Let's start with the code showing the structural problem that arises when writing an API with Express. The TypeScript type, runtime validation rules,…

July 19, 202615 min read
BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration
Backend

BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration

When building a user registration API, you eventually find yourself wondering: "Should the HTTP response really be blocked while a welcome email is be…

July 19, 202623 min read