Front-end education for the real world. Since 2018.





Building a typed fetch in TypeScript with conditional types and infer

Sophie Koonin

Topic: TypeScript

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