~/posts/typed-api-builders

API builders without the generated code

TypeScript’s type system has gotten pretty damn cool.

Building a typed API client from a schema used to always require code generation: a tool reads your spec, produces a method for every endpoint, and you re-run it whenever the contract changes. That works, but it also means maintaining a bunch of wrapper code that exists only to restate what the schema already says.

What’s great is that you can now let the type system derive the API surface completely on its own, including auto-complete and compile-time strictness.

Let’s walk through the pattern. It has two parts: a generic runtime and a type layer.

How the runtime works

The runtime is built on JavaScript’s Proxy. A Proxy lets you intercept property access, so when you write api.users.$id("42").posts it still works, even though there are no actual users or posts properties defined anywhere. Each property access just records a path segment. When the chain ends with .get(), the runtime joins the segments into a URL and fires the fetch:

function createClient(baseUrl: string, path: string[] = []): any {
return new Proxy(
{},
{
get(_, key: string | symbol) {
if (typeof key !== "string") {
return undefined
}
if (key === "get") {
return () => fetch(`${baseUrl}/${path.map(encodeURIComponent).join("/")}`)
}
if (key.startsWith("$")) {
return (value: string) => createClient(baseUrl, [...path, value])
}
return createClient(baseUrl, [...path, key])
}
}
)
}
const api = createClient("/api")
api.users.$id("42").posts.get()

I’m only showing get() here. post(), put(), and friends follow the same pattern.

On its own, this runtime accepts anything. It will happily build api.userz.$id("42").postzz.get() without complaint. It has no idea which paths are actually valid, so it needs some structure. That’s where TypeScript comes in.

TypeScript’s recursive conditional types and template literal types let you describe a client, and it grows as you traverse it, while also narrowing what the next valid step can be.

I’m going to use $id for path params below because it keeps the examples easy to follow. The same idea can support other call styles, but that’s not the interesting part.

If your contract has /users/{id}/posts, the client shape you want in the editor is not a string key called "/users/{id}/posts". You want api.users.$id("42").posts.get(). That’s a perfect type-level translation job:

type BuilderKey<S extends string> =
S extends `{${infer Param}}` ? `$${Param}` : S
type RequestLeaf = {
get(): Promise<unknown>
}
type PathToChain<Path extends string> =
Path extends `/${infer Rest}`
? PathToChain<Rest>
: Path extends `${infer Head}/${infer Tail}`
? Head extends `{${string}}`
? { [K in BuilderKey<Head>]: (value: string) => PathToChain<Tail> }
: { [K in BuilderKey<Head>]: PathToChain<Tail> }
: Path extends `{${string}}`
? { [K in BuilderKey<Path>]: (value: string) => RequestLeaf }
: { [K in BuilderKey<Path>]: RequestLeaf }
type UserPosts = PathToChain<"/users/{id}/posts">
/*
type UserPosts = {
users: {
$id: (value: string) => {
posts: {
get(): Promise<unknown>
}
}
}
}
*/

That isn’t the whole client of course, but it demonstrates something cool: a path string can become a nested object and function shape that matches how you want to call it. Static method names don’t need to be written out by hand if the type system can derive them.

The example above translates one path. A real API has many, and some share prefixes. /users and /users/{id} both start with users, so the final client needs api.users.get() and api.users.$id("42").get() on the same branch. After merging, that branch looks more like this:

type UsersBranch = {
users: {
get(): Promise<User[]>
$id(value: string): {
get(): Promise<User>
}
}
}

Merging those shapes is more utility-type work, but it’s the same idea: TypeScript is building the client surface from path strings.

Where the safety comes from

A typed builder needs a source of truth that exists outside the builder. OpenAPI is a great fit because packages like openapi-typescript can turn a spec into path types. For the builder, the useful part looks roughly like this:

type Paths = {
"/users": { get: User[] }
"/users/{id}": { get: User }
"/users/{id}/posts": { get: Post[] }
}

Now the client type can derive from keyof Paths instead of from strings you invented by hand. api.users.$id("42").posts.get() is typed because /users/{id}/posts exists in the contract. api.userz.get() fails because no such path exists.

The rough parts

None of this is free. The rough part isn’t runtime correctness, it’s editor pain.

Very large schemas and heavily nested recursive mappings can make tsserver miserable. You’ll notice it as autocomplete lag, jump-to-definition pauses, and error messages that spit out a bunch of tangled conditional types.

There are a few ways to mitigate this. First, keep your type utilities focused on path shape and request/response shapes. It’s tempting to also encode things like auth headers, retry config, or caching rules into the same generic types, but every concern you thread through the same recursive utility makes tsserver work harder, so it’s better to handle those concerns at the runtime layer instead.

Second, extract the branches you use most often into standalone type aliases. Without these, every hover in your editor forces tsserver to recursively expand the full schema into nested objects. Pre-resolved aliases short-circuit that expansion:

// The full client type, derived once from the schema
type Api = SchemaClient<Paths>
// Pre-resolved aliases for the branches you use day-to-day
type UsersApi = Api["users"]
type UserDetailApi = ReturnType<UsersApi["$id"]>
// response types stay attached to the branch
type ListUsers = ReturnType<UsersApi["get"]> // Promise<User[]>
type GetUser = ReturnType<UserDetailApi["get"]> // Promise<User>

If one schema is still too large after all that, split it into separate clients by domain.

The extra work may not always be worth it compared with generated code, but the fact that the type system can do this at all is pretty cool. It keeps getting better at this kind of structural reasoning, and it’s worth keeping an eye on where it all goes.