Building a typed fetch in TypeScript with conditional types and infer
Recently, I found myself working with generated types for API requests and responses based off an OpenAPI schema. Using TypeScript’s conditional types, my fetch logic could automatically infer appropriate parameters and response types based on the path I was calling and the HTTP method I was using, which is very cool indeed. I tried looking it up in the documentation to read a bit more about it, but found they didn’t actually go into that much detail about inferring types like this, so I thought I’d share this cool feature a bit wider.
- Code language
- ts
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : never;
I’m going to show you how TypeScript can cleverly extract types out of complex nested structures using the power of the extends
and infer
keywords.
I’ve put together a simplified API schema for this article, and you can explore it — and all the code, live and working — on GitHub. Fire up a GitHub Codespace by pressing . from the repo so that you can see it all in action.
Conditional types tell the compiler to make a decisionpermalink
Think of conditional types like little if-statements within TypeScript: “If this condition applies, it’s this type, otherwise it’s that type.” The syntax looks a lot like JavaScript’s ternary operator, and it’s useful for situations such as writing types for functions that might return different return types depending on what’s passed in:
- Code language
- ts
type myFn = (arg: SomeType | OtherType) => arg extends SomeType ? string : number
You might be familiar with extends
from type declarations where we say “this type X extends another type Y”. When used inside a type declaration, extends
is not like a statement, but a question. “Does schema[P][M]
extend this object?”
If the extends
condition is true, Typescript returns the type after the question mark ?
. If it’s false, it returns the type after the colon :
.
infer
allows us to extract typespermalink
If you look at the TypeScript documentation for conditional types, infer
only gets a couple of small examples. For example, here’s their suggestion for using it to grab the return type of a function:
- Code language
- ts
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : never; type Num = GetReturnType<() => number>; // type Num = number type Str = GetReturnType<(x: string) => string>; // type Str = string type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>; // type Bools = boolean[]
I don’t think this does justice to how neat this little keyword is. You’re essentially saying to the TypeScript compiler, “you tell me what this type is, I don’t want to type the whole thing out”. When we’re dealing with a lot of different types with different combinations of the same properties, this becomes really useful.
Our example: building The Bird Sitepermalink
Here’s the scenario: we’re building a birdwatching website in TypeScript that displays information about different birds and lets you track sightings of birds you’ve spotted. There’s a front-end and an API to fetch the data from.
We want to make sure that the requests we’re making contain the correct arguments, and then we want to be able to use the resulting data we get back in a type-safe way. To do that, we’ll need some types for the API.
We’ve got a spec for the API that we can build against, which tells us exactly which arguments the request takes, and what the response gives us in return. For the purposes of this article, it’s a very simplified API spec.
We’ve run a script to automatically generate types for that spec, and the resulting interface gives us all the paths, their allowed methods, and the parameters and response types of those methods.
Our generated API types look something like this:
- Code language
- ts
export interface schema { '/birds': { get: { parameters: { query?: { /** * @description Filter birds by type * @example waders */ type?: string /** * @description List birds from a specific habitat * @example wetlands */ habitat?: string /** @description Filter birds by colour */ colour?: string } } response: { content: { /** * Format: int64 * @example 10 */ id?: number /** @example Avocet */ name?: string /** * @description What kind of bird it is * @example wader */ type?: string /** * @description Where the bird can be found * @example [ * "lakes", * "wetlands" * ] */ habitats?: string[] /** @description The bird's colours */ colours?: string[] /** @description Any distinctive features to look out for */ distinctiveFeatures?: string /** @description Wingspan in centimetres */ wingspan?: number /** @description URL of image */ image?: string } } } } } "/users": { post: { requestBody?: { content: { /** @description User's name */ name?: string /** @description User's email address */ email?: string /** @description User's favourite birds (bird IDs) */ favouriteBird?: number[] } } response: { content: { /** @example 21 */ id?: number /** @example Billie Sandpiper */ name?: string /** @example [12, 14] */ favouriteBirds?: number[] /** @example [email protected] */ email?: string } } } } }
To make the API calls themselves, we’d generally use the native fetch
function. But this on its own won’t do anything with those types we created.
- Code language
- jsx
const rsp = await fetch('<https://api.example.com/bird/12>') const data = await rsp.json() // no idea what type this is at this point
fetch
is not a generic function, so we can’t pass a type annotation to say what kind of shape the request and response will be:
- Code language
- ts
await fetch<GetBirdRequest, GetBirdResponse>(....) // you can't do this
We could tell fetch
what return type it’s giving us, but that’s very long-winded and inconvenient (and we have to do it every time we use it):
- Code language
- ts
import schema from './api' [...] const rsp = await fetch('<https://api.example.com/birds/12>') const data = await rsp.json() as schema['/bird/{birdId}']['get']['response']['content']
Sure, we could extract these types so we don’t have to be quite so verbose, but then we’d need to do that for every single response type we want.
- Code language
- ts
type ListBirdsResponse = paths['/birds']['get']['response']['content'] type GetBirdResponse = paths['/bird/{birdId}']['get']['response']['content'] type AddSightingResponse = paths['/users/{userId}/sightings']['post']['response']['content'] // I'm bored already.
So, if we want easier type safety in both the parameters and responses, we’ll need to build some kind of wrapper around fetch
that encodes type information. Since we have the generated types for the API schema, we can get TypeScript to tell us the types instead of us specifying them every time: it can infer the params and the response type from just the path and the method of the API call.
We’re going to build a createFetcher
function which takes a path and a HTTP method, and returns another function that knows exactly what params it expects, and exactly what return type you get back.
- Code language
- ts
function createFetcher(path, method) { return async (params) => { // ... } } const getBird = createFetcher('/birds/{birdId}', 'get') const data = await getBird({ path: { birdId: 12 } })
Passing the wrong arguments — or not enough — to getBird
will result in some nice errors:
- Code language
- ts
const data = await getBird({}) // Argument of type '{}' is not assignable to parameter of type 'Params<"/birds/{birdId}", "get">'. // Property 'path' is missing in type '{}' but required in type '{ path: { birdId: number; }; }'.ts(2345)
And hovering over data
will give us the response type:
- Code language
- ts
const data: { id?: number; name?: string; type?: string; habitats?: string[]; colours?: string[]; distinctiveFeatures?: string; wingspan?: number; image?: string; }[]
Building the generic createFetcher functionpermalink
In order to give us the correct types for these requests and responses, our createFetcher
function needs to know:
- what the API schema looks like
- which path in that API schema we are calling, and
- which method we’re using (because params and responses may differ across methods).
The first one’s easy: we can import the types for the API schema in the file where we define this function, so it’ll be in scope.
For the second and third point, we’ll pass in two type parameters to our function, one for the path P
(e.g. /birds/{birdId}
) and one for the method M
(e.g. get
).
- Code language
- ts
import { schema } from './api' function createFetcher<P, M>(path: P, method: M) { return async (params) => { // TODO } }
There’s more — remember that the API schema contains nested objects with the paths as keys, and then the methods as keys of those nested objects:
- Code language
- ts
export interface schema { '/birds/{birdId}': { get: { parameters: { query?: { [...]
We need to explicitly tell TypeScript that the first type parameter, P
(/birds/{birdId}
in the example above) is a key of our schema
, and the second type param, M
(get
in the example) is a key of schema[P]
. We do that with extends
.
- Code language
- ts
import { schema } from './api' function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) { return async (params) => { // TODO } }
The above code tells TypeScript what kind of things it should be able to do with the type parameters we’ve given it. For example, we can use them to look up nested types within our schema
because we’ve specifically told Typescript that P
refers to keys of schema
and M
refers to keys of the objects at schema[P]
.
At this point we’re not giving it a specific path or method, but saying “any path we pass in here should work because it is a keyof schema
”. If we try to use this type with something that doesn’t exist in the schema, the compiler will give us an error.
- Code language
- ts
const wrong = createFetcher("cheese", "get") // error: Argument of type '"cheese"' is not assignable to parameter of type 'keyof schema'.ts(2345)
Fetching the datapermalink
Our createFetcher
function will use fetch
under the hood. (Note: for the purposes of this article we aren’t worrying about error handling, but ordinarily I’d wrap fetch
in a try/catch here and make sure any errors were handled appropriately.)
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) { return async (params) => { const baseUrl = "<https://api.example.com>" const fetchUrl = new URL(path, '<https://api.example.com>') const options: RequestInit = { method: method as string, } const data = await fetch(fetchUrl, options) return await data.json() } }
Note that we need to explicitly cast our method
param (M extends keyof schema[P]
) to a string here in order to assign it to the method property in RequestInit
(which expects a string). Even though we know it is one, it’s technically possible for M
to not be a string (we know it won’t be, but the TypeScript compiler doesn’t.)
This fetch won’t work yet, as we’re fetching with the template string version of the path (e.g. /birds/{birdId}
). We’ll need to get the path values out of the params for the request, but first we need to know what params we’re expecting (and whether we’re expecting any at all).

Unpacking the paramspermalink
Now we’ll need to define our Params
type, which belongs to the argument in our returned partially applied function that does the actual fetching:
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) { return async (params?: Params) => { ... // fetching logic } } // type Params = ???
We’re marking this argument as optional, because some requests might have parameters that can be left out altogether (e.g. a “list all birds” request with optional search query params).
Just like the createFetcher
function, the Params
type also needs to know what schema, path and method it’s dealing with to tell us the correct parameters for that combination of path and method. So this will have exactly the same type params as createFetcher
.
- Code language
- ts
type Params<P extends keyof schema, M extends keyof schema[P]> = {}
Here’s a couple of examples of our parameters in the generated schema types:
- Code language
- ts
"/birds/{birdId}": { get: { parameters: { path: { birdId: number } } ... } }, "/users": { post: { requestBody?: { content: { name?: string email?: string favouriteBirdIds?: number[] } } ... } }
All path + method combinations have parameters
defined in our spec, but they may not actually be used in the request. In the example of POST /users
, query?: never
means that there is no query, and you can leave out the field altogether.
requestBody.content
can contain arbitrary key/value pairs, as it does with POST /users
, or in requests that have no body, the requestBody
field should be left out altogether (as with GET /birds/{birdId}
which has requestBody?: never
in its generated types).
We want to make sure we capture the variety of combinations of parameters in our different requests, without making it so that you have to explicitly provide fields that aren’t needed. And we want to do this automatically.
Inferring types with extends
and infer
This is the clever bit: we’re going to now grab the types of the various parameters from the API spec in a way that will depend on the value of P
and M
.
First we have to check if our type contains the fields we’re looking for with extends
, and then we use the infer
keyword to grab those nested types out of the conditional type. We can then use these inferred types in the type we return from the conditional, in the true branch.
- Code language
- ts
type Params< P extends keyof schema, M extends keyof schema[P] > = schema[P][M] extends { parameters: { query?: infer Q path?: infer PP requestBody?: { content: infer RB } } } ? { query: Q path: PP requestBody: RB : never
Note that our requestBody
is actually a nested object — you can traverse the object until you find the property you want to infer, which is very neat.
Finally, we return never
if the condition doesn’t match because you always have to have two branches with conditional assignment, even if you know that your types will always match the condition you’re evaluating and the second case will never happen.
We can test it by declaring a type for a specific path and method:
- Code language
- ts
type GetBirdParams = Params<"/birds/{birdId}", "get">
Hovering over this in VS Code gives us:
- Code language
- ts
type GetBirdParams = { query: undefined; path: { birdId: number; }; requestBody: undefined }
Let’s now put this Params
type into createFetcher
and see what our compiler says.
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) { return async (params?: Params<P,M>) => { ... } } const getBird = createFetcher("/birds/{birdId}", "get") getBird({ path: { birdId: 1 }})
Unfortunately, the last line gives us a type error:
- Code language
- plaintext
Argument of type '{ path: { birdId: number; }; }' is not assignable to parameter of type '{ query: undefined; path: { birdId: number; }; requestBody: undefined; }'. Type '{ path: { birdId: number; }; }' is missing the following properties from type '{ query: undefined; path: { birdId: number; }; requestBody: undefined; }': query, requestBody
Hmm. The path
field is correct, but it seems we still have to explicitly provide the query
and requestBody
fields to make TypeScript happy. They’re undefined
because /birds/{birdId}
doesn’t have a query string or a request body in the schema, but we declared those fields in our Params
as non-optional.
We’ll change it so that any fields that are not required in these parameters are marked as optional with ?
. We can do that by checking to see which parameters are undefined, and making them optional in the final result. I’m going to create a wrapper type for our Params
to do this, and rename the original Params
to ParamsInner
.
- Code language
- ts
type ParamsInner< P extends keyof schema, M extends keyof schema[P] > = schema[P][M] extends { parameters: { query?: infer Q path?: infer PP } requestBody?: { content: infer RB } } ? { query: Q path: PP requestBody: RB } : never export type Params< P extends keyof schema, M extends keyof schema[P] > = (unknown extends ParamsInner<P, M>['query'] ? { query?: never } : { query?: ParamsInner<P, M>['query'] }) & (unknown extends ParamsInner<P, M>['path'] ? { path?: never } : { path: ParamsInner<P, M>['path'] }) & (unknown extends ParamsInner<P, M>['requestBody'] ? { requestBody?: never } : { requestBody: ParamsInner<P, M>['requestBody'] })
Here we’ve got a union of three conditional types. If a field comes back as unknown
(because our path + method combo doesn’t have it), we mark it as optional with a type of never
(as it should never exist).
If requestBody
or path
is present on our extracted params, we’ll make the necessary fields required. query
should still be optional even if it’s there, as query params may be optional (e.g. optional search filters).
Now let’s make sure our types are looking correct:
- Code language
- ts
const getBird = createFetcher("/birds/{birdId}", "get") getBird({ path: { birdId: 1 }})
Hooray, no errors! Let’s make something wrong just to be sure, by removing the path params completely.
- Code language
- ts
getBird({})
This should error with:
- Code language
- plaintext
Argument of type '{}' is not assignable to parameter of type 'Params<"/birds/{birdId}", "get">'. Property 'path' is missing in type '{}' but required in type '{ path: { birdId: number; }; }'.
Fabulous! Now that the params type is correct, let’s make sure we’re handling parameters properly in createFetcher
.
Replacing template params in the path
First, we’ll check for any path parameters in {curly braces}
in the path that we passed in, and use regex to replace them with the appropriate parameter values. We can match
any char that is not a closing curly brace inside a pair of curly braces with /{([^}])}/
, capturing the chars inside using a capture group (in parentheses) so that we can use it again. match()
gives us an array of matches in the form {paramName}
which we can iterate over, strip the curly braces, and perform string replacements on each instance of them in the provided path.
Again, we need to cast path
to string here as Typescript needs to be sure it’s definitely a string before we do string operations to it.
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>( path: P, method: M, ) { return async (params?: Params<P, M>) => { /** New bit **/ const pathParams = path.match(/{([^}]+)}/g); let pathWithValues = path as string; if (params?.path) { pathParams?.forEach((param) => { const paramName = param.replace(/{|}/g, ""); pathWithValues = pathWithValues.replace( param, params?.path?.[paramName], ); console.log({ paramName, path: pathWithValues }); }); } /** End of new bit **/ const fetchUrl = new URL(pathWithValues, "<https://api.example.com>"); const options: RequestInit = { method: method as string, }; const data = await fetch(fetchUrl, options); return await data.json(); }; }
Appending the query string
We can then add the query
to the URL if there is one, by appending each query key/value pair to our URL’s search params:
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>( path: P, method: M ) { return async (params?: Params<P, M>) => { const templateParams = path.match(/{([^}]+)}/g) let realPath = path as string if (params?.path) { templateParams?.forEach((templateParam) => { const paramName = templateParam.replace(/{|}/g, '') realPath = realPath.replace(templateParam, params?.path?.[paramName]) }) } const baseUrl = '<http://api.example.com>' const fetchUrl = new URL(realPath, baseUrl) /** New bit **/ if (params?.query) { Object.entries(params.query).forEach(([key, value]) => { fetchUrl.searchParams.append(key, value as string) }) } /** End of new bit **/ const baseUrl = '<http://api.example.com>' const options: RequestInit = { method: method as string, } const data = await fetch(fetchUrl, options) return await data.json() } }
Sending the request body
Finally, we add in the request body if it’s there. This will need to go in the fetch options along with the appropriate content type (for the sake of this article it’s JSON).
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>( basePath: P, method: M, ) { return async (params?: Params<P, M>) => { const templateParams = path.match(/{([^}]+)}/g); let realPath = path as string; if (params?.path) { templateParams?.forEach((templateParam) => { const paramName = templateParam.replace(/{|}/g, ""); realPath = realPath.replace(templateParam, params?.path?.[paramName]); }); } const baseUrl = "<http://api.example.com>"; const fetchUrl = new URL(realPath, baseUrl); if (params?.query) { Object.entries(params.query).forEach(([key, value]) => { fetchUrl.searchParams.append(key, value as string); }); } const options: RequestInit = { method: method as string, }; /** New bit **/ if (params?.requestBody) { options.body = JSON.stringify(params.requestBody); options.headers = { "Content-Type": "application/json", }; } /** End of new bit **/ const baseUrl = "<http://api.example.com>"; const data = await fetch(fetchUrl, options); return await data.json(); }; }
Wrangling the response typepermalink
Now we know what we need to pass in, it’s time to define what the function gives us back at the end. Right now, our response type is any
, which is the default return type of Response.json()
.
All requests have a response, even if it’s an empty one. In this article, for the sake of simplicity, we’re only going to have one possible response for each path and method combination.
Our /bird/{birdId}
path, for example, will return information about a bird:
- Code language
- ts
[...] "/birds/birdId": { get: { [...] responseBody: { content: { id?: number /** @example Avocet */ name?: string /** * @description What kind of bird it is * @example wader */ type?: string /** * @description Where the bird can be found * @example [ * "lakes", * "wetlands" * ] */ habitats?: string[] colours?: string[] /** @description Any distinctive features to look out for */ distinctiveFeatures?: string /** @description Wingspan in centimetres */ wingspan?: number /** @description URL of image */ image?: string } } } }
Just like we did before, we’ll extract these types using extends
and infer
, passing in the same type parameters to our Response type (which I’ve called ResponseT
to distinguish it from the native Response
).
Like responseBody
, response
contains a nested object containing the data we want; but response
will always be there, never optional, so we don’t need to worry about that. We can pull out the type using infer
again:
- Code language
- ts
type ResponseT<P extends keyof schema, M extends keyof schema[M]> = schema[P][M] extends { response: { content: infer R } } ? R : never
Because Response.json()
returns Promise<any>
, we need to cast the return type to ResponseT<P, M>
.
- Code language
- ts
[...] return fetch(fetchUrl, options).then( (res) => res.json() as ResponseT<P, M> )
The completed typed fetch functionpermalink
- Code language
- ts
function createFetcher<P extends keyof schema, M extends keyof schema[P]>( path: P, method: M ) { return async (params?: Params<P, M>) => { const templateParams = path.match(/{([^}]+)}/g) let realPath = path as string if (params?.path) { templateParams?.forEach((templateParam) => { const paramName = templateParam.replace(/{|}/g, '') realPath = realPath.replace(templateParam, params?.path?.[paramName]) }) } const baseUrl = '<http://api.example.com>' const fetchUrl = new URL(realPath, baseUrl) if (params?.query) { Object.entries(params.query).forEach(([key, value]) => { fetchUrl.searchParams.append(key, value as string) }) } const options: RequestInit = { method: method as string, } if (params?.requestBody) { options.body = JSON.stringify(params.requestBody) options.headers = { 'Content-Type': 'application/json', } } return fetch(fetchUrl, options).then( (res) => res.json() as ResponseT<P, M> ) } }
Let’s see what the compiler makes of it:
- Code language
- ts
const getBird = createFetcher('/birds/{birdId}', 'get')
Hovering over getBird
gives us:
- Code language
- ts
(params?.requestBody) { init const getBird: (params?: Params<"/birds/{birdId}", "get"> | undefined) => Promise<{ id?: number; name?: string; type?: string; habitats?: string[]; colours?: string[]; distinctiveFeatures?: string; wingspan?: number; image?: string; }>
Looking good! Let’s try another one:
- Code language
- ts
const addSighting = createFetcher('/users/{userId}/sightings', 'post')
This gives us:
- Code language
- ts
const addSighting: (params?: Params<"/users/{userId}/sightings", "post"> | undefined) => Promise<{ id?: number; birdId?: number; timestamp?: string; lat?: number; long?: number; notes?: string; }>
When we want to actually make the API call, we can call each of these functions just like any async function:
- Code language
- ts
const listBirds = createFetcher('/birds', 'get') const allBirds = await listBirds() const getBird = createFetcher('/birds/{birdId}', 'get') const bird = await getBird({ path: { birdId: 12 } }) const addSighting = createFetcher('/users/{userId}/sightings', 'post') const mySighting = await addSighting({ path: { userId: 1 }, requestBody: { birdId: 226, timestamp: '2025-06-04T13:00:00Z', lat: 51.4870924, long: 0.2228486, notes: "I heard it singing in a tree!", }, })
And there you have it — how to use conditional types and infer
to create a fully typed fetch function.
Enjoyed this article? You can support us by leaving a tip via Open Collective

Newsletter
Loading, please wait…
Powered by Postmark - Privacy policy