10LOC
TypeScriptadvanced

Conditional types + infer to extract a function's return type

Published October 20, 2026

type AsyncReturnType<Fn extends (...args: never[]) => unknown> = Fn extends (
  ...args: never[]
) => Promise<infer R>
  ? R
  : Fn extends (...args: never[]) => infer R
    ? R
    : never;

export const fetchUser = async (id: string) => ({ id, name: "Ada Lovelace" });

export type FetchedUser = AsyncReturnType<typeof fetchUser>;

What

AsyncReturnType<Fn> extracts what Fn actually resolves to: the awaited value for a function returning a Promise, or the plain return type otherwise. FetchedUser shows it applied to an async function — it resolves to the object the promise contains, not Promise<...>.

Why it matters

TypeScript ships ReturnType<Fn>, but for an async function that just gives you Promise<User>, not User — you'd still need to wrap every use site in Awaited<ReturnType<Fn>>. AsyncReturnType folds that into one utility with two conditional branches: check for the Promise<infer R> shape first and unwrap it, and only fall back to a plain infer R if the function isn't async at all. This is the pattern underneath a lot of "give me the shape of what this API call returns" utilities you'd otherwise write by hand per function.

How it works

  • The first branch matches functions whose return type fits Promise<infer R>R captures whatever's inside the promise.
  • The second, nested branch handles the non-promise case: infer R here just captures the plain return type.
  • (...args: never[]) => unknown as the constraint (rather than (...args: any[]) => any) keeps the utility from accepting non-function values while still matching functions of any parameter list — never[] is assignable to any parameter tuple because of how function parameter variance works, whereas a concrete parameter type wouldn't be.
  • typeof fetchUser feeds the actual function's type into AsyncReturnType; TypeScript resolves the conditional at the type level with no runtime cost.

Gotchas

  • Conditional type branches are checked in order — putting the plain infer R branch first would match every function, including async ones, and you'd get Promise<User> back instead of User. Order matters whenever branches overlap.
  • This only unwraps one level of Promise. A function returning Promise<Promise<T>> (unusual, but Promise.resolve(somePromise) can produce it) would need Awaited<...> layered on top, since Awaited unwraps recursively and this doesn't.

Related

  • Awaited<T> — TypeScript's built-in recursive promise unwrapper; reach for it directly once you already have the promise type and don't need the "extract from a function" step.
  • Template literal types + infer — the same capture mechanism, matching against string patterns instead of a function's shape.