Modern API Development with TypeSpec and OpenAPI

Designing robust and well-documented web APIs is a crucial part of modern software development. One of the most widely adopted standards for describing RESTful APIs is OpenAPI. It provides a machine-readable format—typically written in JSON or YAML—that defines the structure, endpoints, request/response formats, and other aspects of an API. This schema becomes a single source of truth, powering everything from interactive documentation to client libraries and server stubs.

However, writing and maintaining OpenAPI specifications by hand can quickly become a chore. The syntax is verbose, and small mistakes in the structure can lead to frustrating bugs or inconsistencies across different parts of the system.

This is where TypeSpec comes in.

TypeSpec is a language developed by Microsoft for describing APIs in a more developer-friendly way. Instead of wrestling with raw OpenAPI YAML, you write a TypeSpec file that feels more like TypeScript: concise, expressive, and modular. From this single file, you can generate a complete OpenAPI schema. It acts as an abstraction layer that not only simplifies schema authoring but also enables powerful code generation workflows—both on the backend and frontend.

In this post, we’ll explore how adopting a schema-first API development approach using TypeSpec and OpenAPI can streamline your workflow, improve consistency across systems, and reduce boilerplate. We'll also dive into how to generate backend server code and frontend client libraries directly from your schema—ensuring that all parts of your stack speak the same language.

Project Overview

To demonstrate a schema-first approach to building web APIs, we'll walk through a small example app structured around a clear separation of concerns. The project is organized into four main folders:

.
├── backend
├── docs
├── frontend
└── schema

At the heart of the project is the schema directory. This is where we define our API using TypeSpec. TypeSpec provides a clean, modular language for authoring API contracts, making it easier to manage and evolve the schema over time.

From this source, a script generates a compliant OpenAPI specification. This OpenAPI file then serves as the foundation for the rest of the stack:

  • Backend (backend/): A Go application that uses oapi-codegen to generate server interfaces and type-safe request/response models directly from the OpenAPI schema. This ensures the implementation is always aligned with the contract.
  • Frontend (frontend/): A TypeScript app that uses openapi-typescript to generate a typed client for making API calls. This eliminates the need to hand-write HTTP calls or manually keep types in sync.
  • Documentation (docs/): An interactive HTML file generated using @redocly/cli, offering a polished developer-facing interface to explore and test the API.

Each of these components is tightly coupled to the schema, enabling a single source of truth to drive consistency, reduce boilerplate, and improve developer productivity. In the next sections, we’ll dive into each part in more detail—starting with the schema definition in TypeSpec.

Defining the Schema with TypeSpec

The foundation of this project is a TypeSpec schema that defines the API surface in a structured and composable way. TypeSpec allows you to modularize your API contract into logical parts, making the schema easier to read, maintain, and extend.

Schema Structure

The schema is organized into three main parts:

.
├── main.tsp
├── models
│ ├── default.tsp
│ ├── forms.tsp
│ ├── jobs.tsp
│ ├── search.tsp
│ ├── subscriptions.tsp
│ └── users.tsp
└── routes
├── forms.tsp
├── jobs.tsp
├── search.tsp
├── subscriptions.tsp
└── users.tsp

The Entry Point: main.tsp

The main.tsp file acts as the root of the schema. It imports all route definitions and binds them to specific base paths using the @route decorator. It also defines the service metadata, such as the title and server URL.

import "@typespec/http";
import "./routes/jobs.tsp";
import "./routes/users.tsp";
import "./routes/search.tsp";
import "./routes/subscriptions.tsp";
import "./routes/forms.tsp";

using TypeSpec.Http;

@service({
    title: "JobSearch",
})
@server("http://localhost:8080", "API endpoint")
namespace JobSearch;

@route("/jobs")
interface Jobs extends global.Jobs.Routes {}

@route("/users")
interface Users extends global.Users.Routes {}

@route("/search")
interface Search extends global.Search.Routes {}

@route("/subscriptions")
interface Subscriptions extends global.Subscriptions.Routes {}

@route("/forms")
interface Forms extends global.Forms.Routes {}

This setup gives you a clear overview of all the top-level resources your API exposes.

Defining Routes

Each route file in the routes/ folder defines a logical group of endpoints—one per resource. For example, search.tsp describes all endpoints related to user search functionality:

import "@typespec/http";
import "../models/default.tsp";
import "../models/search.tsp";

using TypeSpec.Http;
using DefaultResponse;

namespace Search;

interface Routes {
    @tag("Search")
    @route("")
    @get
    @useAuth(BearerAuth)
    getSearch(): DefaultResponse.Response<SearchModel.Search[]>;

    @tag("Search")
    @route("/subscribed")
    @get
    @useAuth(BearerAuth)
    getSearchSubscribed(): DefaultResponse.Response<SearchModel.Search[]>;

    @tag("Search")
    @route("/byName/{name}")
    @get
    @useAuth(BearerAuth)
    getSearchByName(
        @path name: string,
    ): DefaultResponse.Response<SearchModel.Search>;

    @tag("Search")
    @route("")
    @post
    @useAuth(BearerAuth)
    saveSearch(
        @body saveSearch: SearchModel.SearchRequest,
    ): DefaultResponse.Response<boolean>;

    @tag("Search")
    @route("")
    @delete
    @useAuth(BearerAuth)
    deleteSearch(
        @body name: {
            name: string;
        },
    ): DefaultResponse.Response<boolean>;
}

These route definitions are strongly typed, decorated with HTTP verbs, and linked to shared response types.

Reusable Models

The models/ directory holds all the data structures used throughout the API. These models are imported into the route files to define request and response types.

namespace SearchModel;

model Search {
    name: string;
    companies?: string[];
    countries?: string[];
    states?: string[];
    cities?: string[];
    title?: string;
    subscribed: boolean;
    createDate: string;
}

model SearchRequest {
    name: string;
    companies?: string[];
    countries?: string[];
    states?: string[];
    cities?: string[];
    title?: string;
    subscribed: boolean;
}

By defining models in one place and reusing them across routes, you ensure consistency across the entire schema and reduce duplication.

Why This Structure Works

  • Scalability: As the API grows, new route files and models can be added without cluttering a single massive schema file.
  • Reusability: Models can be shared across routes, and common response patterns (e.g., wrapped responses, error formats) can be abstracted.
  • Readability: Developers can quickly locate and understand a specific part of the API without wading through unrelated details.

Once the schema is defined, generating the OpenAPI document is straightforward. After installing TypeSpec following the official documentation, you can compile the schema with a single command:

tsp compile .

This will produce an OpenAPI-compliant JSON file, which serves as the source of truth for all the components in the application—from backend logic to frontend clients and documentation.

Backend Implementation

The backend for this app is written in Go and leverages the power of the generated OpenAPI schema to ensure type safety, validation, and consistency.

Generating Go Code with oapi-codegen

Once the OpenAPI schema is generated from the TypeSpec source, we use oapi-codegen to generate Go server boilerplate. This tool creates:

  • Type-safe request and response models
  • Interface definitions for handlers
  • Validation logic for incoming requests based on the schema

This means there's no need to manually parse JSON, check required fields, or write boilerplate validation code.

The following code is used to generate the server:

./oapi-codegen -package generated  -generate "std-http-server, models, strict-server" schema/tsp-output/\@typespec/openapi3/openapi.yaml > backend/generated/server.go

Compiler-Enforced Contracts

The beauty of this approach is that your API implementation is now driven by the schema:

  • Inputs are validated and unmarshaled before they reach your logic.
  • All request/response types are generated, ensuring type safety.
  • Only valid responses (as defined in the schema) can be returned—invalid responses won’t compile.

This eliminates a large class of potential runtime bugs and ensures that the backend stays in sync with the schema.

Focus on Business Logic

With all the plumbing generated automatically, developers can focus on what really matters: the business logic. Here is one example of a handler using the generated request and response types:

func (s *Server) SearchGetSearch(ctx context.Context, request generated.SearchGetSearchRequestObject) (generated.SearchGetSearchResponseObject, error) {
    userContext := helpers.UserContextFromContext(ctx)
    searches, err := s.models.SearchModel.GetSearch(userContext.Email)
    if err != nil || searches == nil {
        return generated.SearchGetSearch200JSONResponse{
            Error:      helpers.SearchNotFoundErrror,
            StatusCode: 404,
            Data:       []generated.SearchModelSearch{},
        }, nil
    }
    searchesResponse := make([]generated.SearchModelSearch, len(searches))
    for i, search := range searches {
        searchesResponse[i] = generated.SearchModelSearch{
            Name:       search.Name,
            Companies:  helpers.ArrayStringFromPossibleNullString(search.Companies),
            Countries:  helpers.ArrayStringFromPossibleNullString(search.Countries),
            States:     helpers.ArrayStringFromPossibleNullString(search.States),
            Cities:     helpers.ArrayStringFromPossibleNullString(search.Cities),
            Title:      search.Title,
            Subscribed: search.Subscribed,
            CreateDate: search.CreateDate,
        }
    }
    return generated.SearchGetSearch200JSONResponse{
        StatusCode: 200,
        Data:       searchesResponse,
        Error:      generated.DefaultResponseError{},
    }, nil
}

Only the methodes defined in the generated interface need to be implemented, and the rest—validation, decoding, and even documentation—is already taken care of.

Frontend Integration

The frontend is a TypeScript application that consumes the API through a fully typed, auto-generated client. We use openapi-typescript to generate this client directly from the OpenAPI schema.

Schema-Driven Client Generation

After generating the OpenAPI schema, we run:

npx openapi-typescript --path-params-as-types -t true schema/tsp-output/\@typespec/openapi3/openapi.yaml -o frontend/src/generated/schema.ts

This produces a complete set of typed interfaces and client scaffolding for making API calls. There’s no need to manually construct fetch requests or maintain duplicate type definitions—the entire contract between frontend and backend is enforced by the compiler.

Benefits:

  • Type-safe API calls: Every request and response is fully typed, reducing bugs and improving developer confidence.
  • Always in sync with the backend: Because both are generated from the same schema, frontend and backend can’t accidentally drift apart.
  • No manual wiring: You don’t need to write custom fetch logic for each endpoint—the generated client handles paths, query params, and bodies for you.

Example Usage

Once the client is generated, using it is as simple as calling a method:

const { data: requestData, error } = await apiClient.GET("/jobs/apply", {
  headers: { Authorization: `Bearer ${user.token}` },
});

The client exposes all defined routes as methods, with strict typings for parameters and return values. If the schema changes, TypeScript will let you know exactly what to update.

API Documentation

The final piece of the stack is developer-facing documentation. Using the same OpenAPI schema, we can generate beautiful, interactive docs with almost no extra effort. For this, we use @redocly/cli, a command-line tool provided by the creators of Redoc.

Generating the Docs

Once the OpenAPI schema is available, generating a static HTML documentation page is as simple as:

npx @redocly/cli build-docs schema/tsp-output/\@typespec/openapi3/openapi.yaml
mv redoc-static.html docs/index.html

This produces a standalone HTML file with a fully interactive interface. It displays all endpoints, grouped by tags, along with detailed information about parameters, request bodies, and response types—directly sourced from the schema.

Benefits:

  • Zero maintenance: The docs are always up to date, because they’re generated from the exact same schema used by the backend and frontend.
  • Interactive exploration: Developers can browse endpoints, view example requests and responses, and explore models without needing external tools.
  • Easy sharing: Since it’s just an HTML file, it can be hosted anywhere—on GitHub Pages, a CDN, or as part of your app’s static assets.

With this setup, your API becomes not just well-typed and maintainable, but also easy to understand and adopt—for your team or third-party consumers. And the best part? Everything comes from a single source of truth: the schema.

Conclusion

By starting with a TypeSpec schema and generating everything else from it, we’ve built a modern web API stack that is:

Fully Typed, End-to-End

From request payloads in the frontend to the response handlers in the backend, every interaction is backed by strong typing. TypeScript and Go both leverage their type systems to enforce correctness—errors that might have slipped through as runtime bugs are now caught at compile time.

Schema as the Single Source of Truth

There’s no duplication of types or documentation across layers. The schema defines everything: the API surface, the request/response contracts, and the documentation. Backend developers, frontend developers, and external consumers all speak the same language, and that language is defined once—in TypeSpec.

Sync Guaranteed by Code Generation

Because both the backend and frontend code are generated from the same OpenAPI file, they can’t fall out of sync. If the schema changes, the compiler will force you to update your implementation. This reduces the risk of breaking changes and misaligned expectations.

Less Boilerplate, More Business Logic

Instead of writing the same validation, parsing, and typing logic over and over, developers can focus on what matters: solving problems. The server code generated by oapi-codegen handles validation and unmarshalling. The client code generated by openapi-typescript handles typing and routing. You’re free to work on actual features.

High-Quality, Zero-Maintenance Documentation

Redoc gives you a polished, interactive API reference with almost no extra effort. It's always up to date, thanks to the schema, and can be shared as a single static HTML file—perfect for internal teams or external partners.

This approach streamlines the entire API lifecycle. It leads to faster development, fewer bugs, better DX, and happier teams. Whether you're building something small or scaling a large system, schema-first development with TypeSpec and OpenAPI can dramatically improve the way you design, implement, and consume web APIs.

Comments

  1. Thanks so much for writing this up and for using TypeSpec — we love seeing developers explore modern API development with it!

    Just a small heads-up: there’s a correction to one of the examples you shared on `@service` decorator. I have attached a playground link at bottom that contains the correction and few other optimization suggestions.

    Also, exciting news — TypeSpec is coming out of release candidate soon with the upcoming 1.0 release! We’d love to hear more from you and others in the community — join the conversation on our community page at https://typespec.io. Keep up the great work!

    https://typespec.io/playground/?c=aW1wb3J0ICJAdHlwZXNwZWMvaHR0cCI7DQoNCnVzaW5nIFR5cGVTcGVjLkh0dHDFGC8vIENvbW1lbnQgIzEuIFBsZWFzZSBub3RlIHRoZSBhZGRpdGlvbiBvZiBgI2AgaW5kaWNhdMRMaXQgaXMgdmFsdWUgxHEuIFRoaXMgd2FzIGEgYnJlYWvEJmNoYW5nZWQgYmV0d2VlbsQIYSBhbmQgUkMgcmXFbi4NCkBzZXJ2aWNlKCN7IHRpdGxlOiAiSm9iU2VhcmNoIiB9KccjZXIoIuQA1DovL2xvY2FsaG9zdDo4MDgwIiwgIkFQSSBlbmRwb2ludCIpDQpuYW1lc3BhY2UgyUvlAPNAcm91dGUoIi9zxl8pDQppbnRlcmbEK8YoIGV4dGVuZHPHD05TLlLENHMge33nATUtzAEgRnJvbSBvdGhlciAudHNwIGZpbGVz7ACCyEggew0KICDsAXUy5AE4ZXNlIGRlY29yYXRvcnMgY2HkASkgYXBwbGllZCBhdCBkaWZmZXLEMWxldmVsLiBgQHVzZUF1dGhgyC5nbG9iYWxseck3b27lAcPnAVsg5QCNYXBjZSBhcyB3ZWxs5ACKQHRhZygi5wFoKcUSx1goQmVhcmVyxAvFGOoBI%2BgBC8QW7gDMMy7pAmIgYWxsb3dzIGN1c3RvbSBkZWZpbuQAlHBlcmHlAlF0ZW1wbGF0ZXMuIEl0IHdvdWxkIGZ1cuUBRMU8IHJldXNlLCBlbmZvcmNlcyBzdGFuZGFyZCBwYXR0ZXJucyzlAklzaW1wbGlmeSBjb2Rl5wCUKiogTGlzdMRJ5AK65AEHZXhpc%2BUCuOYA7WVzICovxi5nZXTGFOQC0MQ3T%2BgApTzGGE1vZGVsLsYMPuUCQ9Flc3Vic2NyaWJlZNJg6gJwySXmAWvLe1PKQP8Ahe8AhesBfjTpA79leHBsb3Jl6wI25AMcZG9jdcQqxVbkAiZyb2HlAKlvZiDmBB9gQGRvY2DqAorlAWBg5ADpKi%2FEF3YgY8dtYmxvY2su6gEJR2V06AM7YnnlAmvGHCAqIEBwYXJhbcUUOuQC68UK6AGW5gODyCzwATFieU5hbWXoAS1AZ2V07wE3QsUhKMdAxGt0aMdqc3RyaW5nLMYbKTogRGVmYXVsdFJlc3BvbnNlLsgJ%2FwHkKiBTYXZlIGHnAKogcmVxdWVzdOkArHNhdmXqAj5DcmVhdGX8AbtSxkD2AcI1LiBTdGF0dXPlAswgMjAwLzIwNCArIGVycm9yxhVhcmUgZuUAhm50bHkgdXPkAjPmBZZlZCBzdWNjZXNzL25vdCBmb3VuZC%2FGO3dpdGhvdXQgbmVlZOQB6mEgcmV0dXJuIGJvZHnnAV9kZWxldOcBuMYM5gCz6gFfxC3nAV%2FnA%2F7EAewBcDvIF33%2FAXrkAXpib29sZWFu5AEMICB9DQrlBVzwBTTlAT3Gb%2BQBjuUGZGlz6AGPbcUe5gGNxgzGNsYx9QCcY29tcGFuaWVzP8gZW13JG3VudHLVG3N05ASs0zNpdNUw5QbGyRfIRekD5jog5wD9yEflAjlEYXTuAK7lARLuANrnAkHpBX%2FlAqJtcGxl5APg5QHo5QOpxA3lA6XGJyAqIGBgYMkMICBNYXJrRG93btQf5wLb7wE8xhXkBCIoIsdyRG9j6QQSxXrEHuoEP%2BgDufcBcv8BdP8BdP8BdP8BdPQCcO8CqOYBYuYBe8gUPFQ%2BxhcgIC4uLk9ryBjHbHJlc3VsdDogVM1i5AS4IG9w7wXWVD4o%2FAMcVFtd5gQkQHBvc8U%2F8ARcVOgES%2BUDoOYDnF86IMgXLA0K3GDrA3wNCucD%2B8RmRMUK%2FwCn1Uc%3D&e=%40typespec%2Fopenapi3&options=%7B%7D

    ReplyDelete
    Replies
    1. Thanks for the corrections and the great additions in the Playground! I'm looking forward to the 1.0 release. I also saw the preview of the server and client Emitters in the docs — I'm excited about that too!

      Delete

Post a Comment

Popular posts from this blog

Enabling TypeScript Strict Mode in a Legacy React Project—A Gradual Approach