API Magic
There is a moment in every API’s life where somebody asks “can we get a SDK for XYZ?”. Everyone is doing the same mental math: another repository, another set of models to keep in sync, another thing that will silently rot the next time we rename a field.
On a production Go API I work on, that question costs a git tag. CI recompiles the spec, generates the clients (Python, Angular… you name it), builds the packages, publishes them. Nobody wrote a line of client code. So do the request validators, the server stubs, the Swagger UI, and the docs portal. All of it fans out from one source of truth.
That source of truth is not OpenAPI. It’s TypeSpec. And after living with this pipeline in production, through the wins and through the workarounds, I think the lessons are worth writing down. Both kinds.
The problem: N clients × M endpoints × infinite drift
The classic API story usually goes YOLO. You write handlers. Then someone writes documentation, by hand, in a wiki. Then a frontend team writes a TypeScript client, by hand, from the wiki. Then a data team writes a Python client, by hand, from the TypeScript client. Six months later the wiki says perPage, the TypeScript says per_page, the Python client silently drops the field, and production has an incident.
Every artifact was written by a human, so every artifact drifts independently. The maintenance cost is multiplicative: N clients times M endpoints times every change you ever make.
The fix is old and boring and correct: make the contract a machine-readable artifact and generate everything else from it. Spec-first. The interesting part in 2026 is not the idea. It’s how good the tooling has become, and where it still bites.
What is TypeSpec, and why not just write OpenAPI?
TypeSpec is Microsoft’s language for describing APIs. It looks like TypeScript, compiles like a programming language, and emits OpenAPI (among other things) as build output. Microsoft uses it to describe the entire Azure API surface, which is a decent stress test.
The obvious question: OpenAPI already exists, why put a language in front of it?
Because OpenAPI is a great output format and a miserable authoring format. There is no real reuse, no composition, no type system, just YAML anchors and $ref spaghetti. Here is a paginated list endpoint in TypeSpec:
@list
@route("/foos")
@summary("Foos for a given Account")
@doc("Retrieves all the foos for a given Account.")
@tag("Foos")
listFoos(
@query @pageIndex page?: int32 = PAGE_START_INDEX,
@query @pageSize perPage?: int32 = PER_PAGE_MAX,
):
| FoosDetailsResponse
| BadRequestError
| UnauthorizedError
| ForbiddenError
| InternalServerError;
Eleven lines. The equivalent OpenAPI YAML is around eighty: the operation object, four 4xx/5xx response objects each $ref-ing an error schema, two parameter objects with their own schema blocks, the works. Nobody reviews eighty lines of YAML in a PR. Everybody reviews eleven lines of TypeSpec.
That snippet shows actual language features doing actual work:
@pageIndex/@pageSizeare semantic decorators: pagination is a first-class concept, not a naming convention.- The return type is a union. The success shape and every possible error are part of the operation’s type. Add a new error case and every downstream artifact knows about it.
PAGE_START_INDEXandPER_PAGE_MAXare constants. Change the max page size once, it changes everywhere. Try that with raw YAML.
Errors themselves are models with literal-typed discriminators:
model ErrorResponse {
code: string;
message: string;
details?: string;
}
@error
model BadRequestError extends ErrorResponse {
code: "BAD_REQUEST";
}
@error
model UnauthorizedError extends ErrorResponse {
code: "UNAUTHORIZED";
}
Every operation in the API composes these. One error vocabulary, defined once, enforced by the compiler.
Structuring a real spec
My spec is not a toy. It compiles to roughly 125 operations across a couple dozen domain areas. The layout that emerged:
spec/
├── main.tsp # entry point, routes, operations
├── external-api.tsp # a second, separate service definition
├── asyncapi.tsp # event definitions (more on this later)
├── auth.tsp # security schemes
├── errors/errors.tsp # the shared error vocabulary
└── models/ # 25+ domain model files
├── call.tsp
├── agent.tsp
├── metrics.tsp
└── ...
Two patterns here earned their keep enough to recommend unconditionally.
Interfaces as auth boundaries. TypeSpec interfaces group operations, and decorators apply to the whole group. I expose three surfaces: public (/v1), private/internal (/v1.i), and a read-only external API (/v1.ext), each an interface with its own @useAuth:
@route("/v1")
@useAuth([TokenAuth, InstanceNameAuth])
interface PublicApi {
// every op in here inherits route prefix + auth
}
You cannot accidentally ship an unauthenticated endpoint, because auth is declared at the surface level, not per-handler in middleware someone forgot to register.
Deriving DTOs instead of duplicating them. The external API exposes a subset of internal data. Instead of maintaining parallel models, I derive them:
model ExternalCallContent is OmitProperties<
CallContent,
"diarization" | "language"
>;
When the internal model grows a field, the external model’s relationship to it is explicit and reviewed, not rediscovered during an incident.
One honest note on versioning: TypeSpec has a whole @typespec/versioning library with @added/@removed decorators. I don’t use it. I version by URL surface (/v1, /v1.i, /v1.ext) and it has been enough. Reach for versioning machinery when you have the problem, not before.
OpenAPI as the pivot format
Here is the architectural insight that makes the whole thing work: TypeSpec doesn’t replace OpenAPI, it produces it. The compile step is one Makefile target:
compile-api-spec:
cd api/spec && npx tsp compile main.tsp \
--emit=@typespec/openapi3 --output-dir=../../tsp-output
Out comes openapi.yaml. And that file is the pivot: it’s a lingua franca that every ecosystem already speaks. Any OpenAPI code generator, in any language, published by anyone, can consume it. You are not betting on TypeSpec’s ecosystem; you are betting on OpenAPI’s, which is the safest bet in API tooling. TypeSpec is just a dramatically better tool to write it with.
The emitter config:
options:
"@typespec/openapi3":
emitter-output-dir: "{output-dir}/schema"
openapi-versions:
- 3.0.0
The same toolchain also emits AsyncAPI for our event definitions: a community emitter (@lars-artmann/typespec-asyncapi) turns asyncapi.tsp into an AsyncAPI document plus rendered HTML docs. One language describing both the request/response surface and the event surface is a quietly big deal.
Generating the Go server: the compiler becomes your contract cop
The OpenAPI file feeds oapi-codegen, configured for Echo with strict-server mode:
package: apigen
output: internal/apigen/server.gen.go
generate:
models: true
echo-server: true
strict-server: true
embedded-spec: true
gen-api-code:
cd api/impl && go tool oapi-codegen -config oapi-config.yaml \
../../tsp-output/schema/openapi.yaml
Strict-server mode generates one interface with one method per operation, each with fully typed request and response objects:
type StrictServerInterface interface {
// (GET /health)
GetHealth(ctx context.Context, request GetHealthRequestObject) (GetHealthResponseObject, error)
// List accounts
// (GET /v1.i/accounts)
PrivateApiListAccounts(ctx context.Context, request PrivateApiListAccountsRequestObject) (PrivateApiListAccountsResponseObject, error)
// ... ~125 more
}
My hand-written Server struct implements this interface and delegates to services. The consequence: add an operation to the TypeSpec and the Go build breaks until you implement it. The spec is not documentation that describes the server. The spec is a contract the compiler enforces. Drift between spec and implementation is a compile error.
And it’s belt-and-suspenders. embedded-spec: true compiles the OpenAPI document into the binary, and at startup I wrap the router with oapi-codegen’s Echo middleware, which validates every incoming request against the spec at runtime. Types guarantee the shape of what is returned; the validator guarantees the shape of what it accepts. A request with a bad enum value or a missing required field is rejected before it ever reaches a handler.
The generated code goes into its own package, regenerated by a single make codegen. Handlers, services, repositories (the human code) never touch generated files.
SDKs for free
Back to the moment that opened this post. The same openapi.yaml feeds openapi-generator, and CI does the rest.
Push a tag like python-sdk-v1.2.3 and a GitHub Actions workflow recompiles the TypeSpec, then runs:
npx openapi-generator-cli generate \
-i ./tsp-output/schema/openapi.yaml \
-g python -o ./python-sdk \
--additional-properties=packageName=acme_api_sdk,packageVersion=1.2.3
builds the wheel, and attaches it to a release. The Angular SDK pipeline is the same shape: typescript-angular generator, built with ng-packagr, published to a private npm registry. Total hand-written client code across both SDKs: zero. Total marginal cost of supporting a new endpoint in both SDKs: zero.
Docs come out of the same faucet. The external API serves its own Swagger UI directly from the embedded spec, so the documentation endpoint literally cannot disagree with the running server, because it is the running server’s contract. The spec is also pushed to a hosted docs portal on release. Nobody updates a wiki.
This is the “magic”. One .tsp file changes; a compiler fan-out updates the server interface, the request validator, two SDKs in two languages, and two documentation surfaces. The demo is genuinely magical.
Production, of course, has opinions about magic.
The sharp edges
Everything above is true and I’d rebuild the pipeline tomorrow. But spec-first tooling is a chain of independently maintained open-source projects, and you inherit the chain, not just the links you like.
Codegen bugs become your bugs. TypeSpec unions are lovely to author. But oapi-codegen had a long-standing issue (#970) where union response types generate a struct whose union payload field is unexported: from outside the generated package, you literally cannot construct a valid response. Our fix is a hand-written patch file that lives inside the generated package:
A hand-maintained file in a do-not-edit directory. It’s ugly, it’s documented, it survives regeneration because the generator only overwrites its own output file. When you adopt a generator, its issue tracker becomes part of your architecture. With their latest release this patch can now go away.
Runtime libraries have folklore. The request validator is backed by kin-openapi, whose router matches requests against the spec’s servers: list, including the host. Deploy with real production URLs in servers: and every request to localhost in dev and CI gets a 404 “no matching operation was found”. The fix is one deeply non-obvious line before building the validator:
swagger.Servers = nil
That line cost a few hours of “figuring things out”. It now has a five-line comment above it so it never costs another one.
Your weakest consumer sets your spec version. Remember that YAML comment? It emit OpenAPI 3.0, not 3.1, because for years oapi-codegen didn’t support 3.1, so the entire pipeline, TypeSpec included, ran at the level of its least-capable downstream tool. That’s the general law: you don’t pick your spec version, your toolchain’s intersection picks it. As it happens, there’s a fresh ending to this one: oapi-codegen v2.8.0 shipped OpenAPI 3.1 support just days before I published this article. The pin can finally go. The lesson stays: audit every consumer’s capabilities before you commit to spec features.
Committed generated code can silently go stale. The generated Go code is committed to the repo, and CI regenerates it from the spec on every PR. That proves the spec compiles and the generator runs. But CI never compares its freshly generated code against what is committed. So this can happen: you edit the .tsp file, forget to run make codegen, and push. CI is green, the PR merges, and the committed Go code no longer matches the spec it claims to implement. The fix is to make CI fail when they differ: regenerate, then run git diff --exit-code, which fails the build if the regenerated files are not identical to the committed ones. It’s three lines of YAML.
The AI feedback loop
Libraries like oapi-codegen are maintained by a handful of people on nights and weekends. Issues like the union bug sit open for years not because they’re unsolvable but because maintainer hours are the scarcest resource in open source. AI agents are collapsing that constraint. Triaging, reproducing, drafting the fix, writing the regression test: the grunt work that keeps a backlog frozen is exactly the work agents are good at. It’s hard not to see the timing of long-awaited releases like OpenAPI 3.1 support landing now as part of that broader acceleration. The ecosystem’s weakest links are getting stronger, faster.
And here’s the loop: the beneficiaries of that stronger ecosystem are the AI agents themselves. An agent dropped into a big codebase does its best work when the contracts are explicit and machine-checkable, which is precisely what this pipeline produces. A typed spec, a strict server interface, a runtime validator, generated SDKs: that’s not just developer ergonomics, it’s a rails system that lets an agent modify an API surface and know whether it broke something, at compile time, before a human reviews anything. Spec-first design turns out to be agent-first design.
So the flywheel spins both ways. AI makes the spec-first toolchain better; the spec-first toolchain makes AI agents effective on real systems; effective agents contribute back to the toolchain. If you needed one more reason to invest in API contracts in 2026, it’s that your next thousand contributors might not be human, and this is the interface they read best.
Would I do it again?
Without hesitation. The failure mode of the hand-written world (silent, multiplicative drift across every client and doc) is so much worse than the failure mode of the generated world, which is loud, local, and greppable. Our worst codegen problem is a fifteen-line patch file with a link to an upstream issue. Our worst drift problem before would have been unknowable, by definition.
If you’re about to build this pipeline, my condensed advice:
- Author in TypeSpec, publish OpenAPI. You get a real language and you keep the universal ecosystem. It’s not either/or.
- Check your generators’ feature support first. The intersection of your consumers decides your spec version and which spec features are safe.
- Turn on strict-server mode. Spec/implementation drift should be a compile error.
- Embed the spec and validate requests at runtime. Types cover your outputs; the validator covers your inputs.
- Gate CI on regenerate +
git diff --exit-code. From day one. - Add a spec linter early. Conventions enforced by review don’t scale; Spectral rules do.
- Budget for sharp edges. An issue-tracker link in a comment above a workaround is not a failure of the approach. It’s what production looks like.
The magic is real. But it isn’t effortless, self-sustaining magic like in a fairy tale. It’s more like an impressive machine: it comes with documented workarounds and upkeep chores.
If you have suggestions or thoughts, keep the conversation going on my Substack.