Every development team knows the pattern. The backend ships an endpoint. The frontend integrates against it. Something breaks. A Slack thread erupts: “The response format changed?” “That field was supposed to be nullable.” “Why is this returning an array now?”
This back-and-forth isn’t a communication failure. It’s a systems failure. When the source of truth for an API contract lives in documentation, tribal knowledge, or worse—assumptions—teams will always drift out of sync. The question isn’t whether contract mismatches will cause bugs. It’s how many hours will burn before someone notices.
The typical industry response involves OpenAPI specifications, generated clients, and runtime validation. These help. But they’re still fundamentally disconnected: the schema lives in one place, the server implementation in another, the client in a third. Drift remains possible at every boundary.
There’s a more aggressive solution. What if the API contract lived in the type system itself, and both server and client were derived from that single source? No drift possible. No schema to keep synchronized. Just types, all the way down.
The Servant Approach: APIs as Types
Servant is a Haskell library that takes a radical approach to API definition. Rather than describing endpoints in configuration files or decorators, Servant encodes the entire API structure as a type alias.
Consider a simple endpoint that fetches a user by ID:
type UserAPI = "users" :> Capture "id" Int :> Get '[JSON] UserThis single line declares: there’s a path /users/{id}, where id is an integer captured from the URL, responding to GET requests, returning JSON-encoded User data.
The key insight is that UserAPI isn’t configuration—it’s a type. It exists at compile time. The Haskell compiler can reason about it, and other code can consume it to generate artifacts.
Servant itself is just a library of type-level combinators: :> for path composition, Capture for URL parameters, Get/Post/Put/Delete for HTTP methods, ReqBody for request bodies, and so on. These combinators compose to describe arbitrarily complex APIs entirely within the type system.
What makes this powerful is what happens next. Separate libraries—servant-server, servant-client, servant-swagger—can consume this type and produce:
- A server implementation where handlers are type-checked against the API
- A Haskell client where function signatures match the API exactly
- Swagger/OpenAPI documentation derived directly from the types
All three artifacts stem from one source of truth. If the API type changes, dependent code either adapts or fails to compile. There’s no possibility of silent drift.
The Cross-Language Problem
This approach works beautifully when both client and server are Haskell. But most production systems don’t have that luxury. Frontend teams typically work in TypeScript. Backend teams might choose Haskell, Go, Rust, or any number of languages optimized for their domain.
The challenge then becomes: how do you extend these type-safety guarantees across the language boundary?
The naive answer—generate TypeScript from the Haskell types—sounds simple. The reality is considerably more complex.
The Serialization Trap
Before worrying about generating a TypeScript client, there’s a prerequisite problem: the types themselves. When a Haskell User type serializes to JSON and deserializes in TypeScript, both sides must agree exactly on the wire format.
Haskell’s aeson library (the standard JSON serialization library) makes choices that aren’t obvious. A tuple (Int, String) doesn’t serialize to {"first": 42, "second": "hello"}. It becomes a heterogeneous array: [42, "hello"]. A sum type like Maybe a serializes with tag fields to distinguish Just from Nothing.
Every data type used in the API—request bodies, response payloads, nested structures—must have a TypeScript representation that exactly matches its aeson serialization. Getting this wrong doesn’t produce helpful error messages. It produces runtime failures, malformed data, and debugging sessions that trace through serialization code.
Building aeson-typescript
The first step toward cross-language type safety is a library that generates TypeScript type definitions matching aeson’s behavior. This isn’t a simple mapping exercise.
Haskell’s type system is considerably more expressive than TypeScript’s. A Maybe a in Haskell—a type with two constructors, Just a and Nothing—doesn’t have a direct TypeScript equivalent. The generated code must construct discriminated unions:
interface Left<L> {
tag: "Left";
contents: L;
}
interface Right<R> {
tag: "Right";
contents: R;
}
type Either<L,R> = Left<L> | Right<R>;This is more verbose than the Haskell original, but it’s isomorphic—structurally identical, just expressed in a less powerful type system.
The library uses Haskell’s metaprogramming facilities (Template Haskell and generics) to inspect data types and generate corresponding TypeScript. Critically, it uses the same options that aeson uses for JSON derivation. If aeson is configured to modify field names, drop prefixes, or use different tag encodings, the TypeScript generator must respect those same options.
The result: any type that can be serialized to JSON in Haskell can have TypeScript definitions generated that will correctly parse that JSON. The two representations are guaranteed to agree because they’re derived from the same source using the same rules.
From Types to Clients
With type definitions solved, the second problem is generating the actual HTTP client code. This is where Servant’s architecture proves its worth.
Servant’s type-level API description can be “interpreted” by different consumers. servant-server interprets it into a server. servant-swagger interprets it into documentation. A new library—servant-typescript—interprets it into a TypeScript client.
The generated client isn’t just type-annotated fetch calls. It’s a structured module where each endpoint becomes a function with:
- Parameter types matching URL captures, query parameters, and request bodies
- Return types matching the response body
- Proper handling of different content types
When the API changes, the TypeScript definitions change. When the TypeScript definitions change, the frontend code either adapts or fails to compile. The feedback loop is immediate and unambiguous.
More subtly, the generated client also exposes URL builders. Frontend applications often need endpoint URLs for things like HTML form actions or href attributes. Rather than string-concatenating paths and hoping they’re correct, the URL builder functions are derived from the same API type—guaranteed to produce valid endpoints.
The Business Case
The technical implementation is interesting, but the real question is whether this complexity pays for itself.
API contract mismatches are expensive. Not because individual bugs are catastrophic, but because they create friction at every interaction point. When a frontend developer hits an unexpected response format, the workflow becomes:
- Message the backend team
- Wait for response (minutes to hours, depending on time zones)
- Get clarification or a fix
- Retry
- Possibly repeat
This isn’t one bug. It’s a category of bugs that recurs throughout development. Studies suggest teams using contract-first methodologies experience up to 50% fewer integration issues. That’s not because the code is better—it’s because an entire class of miscommunication becomes structurally impossible.
The upfront investment for building aeson-typescript and servant-typescript was roughly five to six weeks of development time. The ongoing maintenance has been minimal—one or two bug reports over extended production use, and those often turn out to be user error rather than library issues.
Against that, the savings compound:
- Frontend developers can read the generated client to understand API semantics without asking questions
- API changes surface immediately as compile errors rather than runtime failures
- No coordination meetings required to “sync up” on API contracts
- No production bugs from mismatched client/server expectations
The question isn’t whether type-safe cross-language communication saves time. It’s whether the specific implementation cost is justified by the expected savings. For any project where a TypeScript frontend communicates with a Haskell backend over a non-trivial number of endpoints, the math works out decisively in favor of the investment.
Extensibility: The Combinator Model
One underappreciated aspect of Servant’s design is its extensibility. Servant provides common combinators out of the box: Get, Post, Capture, QueryParam, and so on. But the system is designed to be extended.
Need a custom authentication scheme? Define a combinator. Need to support a binary protocol alongside JSON? Define content types. Need to lint APIs for ambiguous routes? Write an interpreter.
This isn’t theoretical. Production Servant deployments routinely include custom combinators for:
- Authentication and authorization schemes
- Request rate limiting
- Custom content types (Protocol Buffers, MessagePack, etc.)
- Audit logging
- API versioning
Each of these extensions participates in the same type-level machinery. The TypeScript generator can be taught to understand custom combinators and emit appropriate client code. The type safety extends to domain-specific concerns, not just HTTP primitives.
This extensibility model suggests a broader pattern. The same technique—encoding specifications as types and deriving multiple artifacts through interpretation—applies beyond HTTP APIs. Binary protocols, RPC systems, message queues, even SPAs with client-side routing can benefit from the same approach. If you can describe the structure, you can derive implementations that are guaranteed to agree.
Trade-offs and Limitations
This approach isn’t without costs.
Haskell’s type-level programming has a learning curve. Teams without Haskell experience face a steeper onboarding path. The error messages from type-level code can be cryptic, and debugging requires understanding how GHC’s type checker works.
Compile times increase with type-level complexity. For large APIs with many endpoints and data types, the TypeScript generation step adds measurable time to the build process. This is usually acceptable for CI/CD pipelines but can slow down tight feedback loops during development.
The generated TypeScript, while correct, isn’t always idiomatic. It represents Haskell’s type system in TypeScript’s notation, which can feel foreign to TypeScript developers accustomed to different patterns. Discriminated unions with tag fields work but aren’t how most TypeScript code is written.
And of course, this approach requires the backend to be in Haskell (or another language with similar type-level capabilities). Teams using Go, Python, or JavaScript on the backend would need to look at different tooling—OpenAPI generators, tRPC, or similar solutions that provide weaker but still useful guarantees.
The Broader Lesson
The specific technologies here—Haskell, Servant, TypeScript—matter less than the underlying principle: APIs are interfaces, and interfaces benefit from formal specification.
When the specification is informal (documentation, conventions, tribal knowledge), drift is inevitable. When the specification is formal but disconnected from implementation (OpenAPI specs maintained separately from code), synchronization becomes a manual process with failure modes.
When the specification is the implementation—when types are the API—the entire category of contract mismatch bugs becomes impossible. The compiler enforces consistency. Humans don’t have to.
This is the real value proposition. Not the elegance of type-level programming or the sophistication of Haskell’s type system. Just the simple engineering reality that automated checks catch errors humans miss, and type systems are very good automated checks.
For teams in a position to adopt this approach, the payoff is substantial: fewer bugs, faster integration, less coordination overhead, and confidence that when something compiles, client and server actually agree on what they’re communicating.
That’s not a small thing. In the endless grind of shipping software, eliminating an entire category of problems is as close to a free win as engineering gets.