DEV Community

Cover image for `Awaited<T>`, `ReturnType<T>`, `Parameters<T>`: When You're Reaching for the Wrong One
Gabriel Anhaia
Gabriel Anhaia

Posted on

`Awaited<T>`, `ReturnType<T>`, `Parameters<T>`: When You're Reaching for the Wrong One


A team I talked to last month shipped a typed API client. The
handler reads ReturnType<typeof fetchUser> and binds that to
its response shape. The endpoint compiles, the test for the
happy path passes, the staging deploy goes out. Two days later a
UI engineer opens a ticket: every field on user.profile is
undefined. The function was async. The aliased shape was a
Promise<User> that nobody noticed. The renderer was reading
.profile off something the alias claimed was a User.

Every TypeScript codebase that has been alive for two years has
this bug somewhere. The four utility types from the handbook
page

that get reached for the most: Awaited<T>, ReturnType<T>,
Parameters<T>, and ConstructorParameters<T>. All four have a
sharp edge that the autocomplete does not warn you about. The
compiler accepts the wrong one. The runtime delivers the wrong
shape. The ticket comes back from QA.

Four bugs you have probably shipped, the fix for each, and the
one rule that prevents three of them.

Trap 1: ReturnType on an async function lies in the most expensive way

The shape of the bug:

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as { id: string; name: string };
}

type User = ReturnType<typeof fetchUser>;
//   ^? Promise<{ id: string; name: string }>

function render(u: User) {
  return u.name;
  // Property 'name' does not exist on type 'Promise<{...}>'
}
Enter fullscreen mode Exit fullscreen mode

That error is the kind a fresh reader of the code spots in two
seconds. The version that ships to production is the one where
User never lands at a use-site that forces the structural
check:

type User = ReturnType<typeof fetchUser>;

// The alias is exported from a types module and used as a
// generic constraint, a public return type, or an `as`-cast
// target. None of those force the value-level await boundary
// the compiler would need to reject the wrong shape.

export async function getUser(id: string): User {
  return fetchUser(id) as User; // both halves are Promise<...>;
                                // the cast is happily accepted
}

const cached = new Map<string, User>(); // key into a cache
                                        // typed by Promise<T>
Enter fullscreen mode Exit fullscreen mode

User is Promise<{ id: string; name: string }>, not
{ id: string; name: string }. The places where the alias
slips through are the ones where you never hand it to a parameter
typed { id, name } directly. The cache stores promises. The
return annotation matches the promise that comes back from the
async function. Downstream code reads from the alias as if it
were the resolved shape and finds nothing.

The fix is one wrapping type:

type User = Awaited<ReturnType<typeof fetchUser>>;
//   ^? { id: string; name: string }
Enter fullscreen mode Exit fullscreen mode

Awaited<T> recursively unwraps PromiseLike types until it
bottoms out on a non-thenable, then returns that. The handbook
calls it the correct way to model the result of await since
TypeScript 4.5. No built-in rule I'm aware of in the current
@typescript-eslint
ruleset
catches the
"ReturnType on a function returning Promise<...> without
Awaited wrap" pattern; if you want one, it is straightforward to
write as a custom rule against the type-checker.

If your codebase has more than ten ReturnType<typeof someAsync>
expressions, run a search for ReturnType<typeof and audit each
hit. The async ones deserve a second look.

Trap 2: Parameters<T> collapses overloads to the last one

This is the trap that catches you when you wrap somebody else's
function.

function open(path: string): FileHandle;
function open(path: string, flags: number): FileHandle;
function open(
  path: string,
  flags: number,
  mode: number,
): FileHandle;
function open(
  path: string,
  flags?: number,
  mode?: number,
): FileHandle {
  return realOpen(path, flags ?? 0, mode ?? 0o644);
}

type OpenArgs = Parameters<typeof open>;
//   ^? [path: string, flags?: number, mode?: number]
Enter fullscreen mode Exit fullscreen mode

OpenArgs is the last overload signature, because that is what
TypeScript exposes when it resolves typeof open for type
extraction. The first two overloads are invisible to
Parameters<>. If you build a wrapper:

function tracedOpen(...args: OpenArgs): FileHandle {
  console.log("open", args);
  return open(...args);
}

tracedOpen("a.txt");
tracedOpen("a.txt", 1);
tracedOpen("a.txt", 1, 0o600);
Enter fullscreen mode Exit fullscreen mode

The wrapper compiles because the implementation signature is
permissive. But the per-overload constraints are gone. The shape
that made open(path) a different call than open(path, flags,
mode)
does not survive the round-trip through Parameters.
Every call falls through to the loosest signature.

The fix depends on what you are trying to do. If you only need to
forward to the call site with the same overload set, do not use
Parameters — re-declare overloads:

function tracedOpen(path: string): FileHandle;
function tracedOpen(path: string, flags: number): FileHandle;
function tracedOpen(
  path: string,
  flags: number,
  mode: number,
): FileHandle;
function tracedOpen(
  path: string,
  flags?: number,
  mode?: number,
): FileHandle {
  console.log("open", path, flags, mode);
  return open(path, flags!, mode!);
}
Enter fullscreen mode Exit fullscreen mode

If you want to extract every overload signature for inspection,
the trick is conditional inference over a tuple of function
shapes:

type Overloads<T> =
  T extends {
    (...args: infer A1): infer R1;
    (...args: infer A2): infer R2;
    (...args: infer A3): infer R3;
  }
    ? [A1, A2, A3]
    : T extends {
          (...args: infer A1): infer R1;
          (...args: infer A2): infer R2;
        }
      ? [A1, A2]
      : T extends (...args: infer A) => unknown
        ? [A]
        : never;

type AllOpenArgs = Overloads<typeof open>;
//   ^? approximately [[string], [string, number],
//      [string, number, number]] — exact tuple depends on which
//      signatures the compiler exposes for `typeof open` and on
//      whether the implementation signature gets folded in;
//      verify in a playground for your TS version
Enter fullscreen mode Exit fullscreen mode

This is the Stack Overflow folklore
answer
that has been
circulating since TS 3.x. Variadic-arity overload extraction is
not expressible in TypeScript today. You hand-write a helper at
the depth your library uses, or you re-declare the wrapper. The
cleaner answer is the second. Overloads are a public-API shape.
Treating them like a generic primitive is where this trap starts.

Trap 3: ConstructorParameters versus InstanceType is one keyword apart

Two utilities that look similar and do the opposite:

class Cache<K, V> {
  constructor(
    private readonly capacity: number,
    private readonly ttlMs: number,
  ) {}
  get(k: K): V | undefined {
    return undefined;
  }
}

type Args = ConstructorParameters<typeof Cache>;
//   ^? [capacity: number, ttlMs: number]

type Inst = InstanceType<typeof Cache>;
//   ^? Cache<unknown, unknown>
Enter fullscreen mode Exit fullscreen mode

The bug pattern is reaching for one when you wanted the other.
The factory wrapper:

function makeCache(...args: ConstructorParameters<typeof Cache>) {
  return new Cache(...args);
}

const c = makeCache(100, 60_000);
//    ^? Cache<unknown, unknown>
Enter fullscreen mode Exit fullscreen mode

That unknown, unknown is what catches teams who ship a generic
class and a factory together. The factory threw away the generic
parameters because typeof Cache is the constructor signature
without instantiation.

The fix is to thread the generics through the wrapper, not lift
them out of the class:

function makeCache<K, V>(
  capacity: number,
  ttlMs: number,
): Cache<K, V> {
  return new Cache<K, V>(capacity, ttlMs);
}

const c = makeCache<string, User>(100, 60_000);
//    ^? Cache<string, User>
Enter fullscreen mode Exit fullscreen mode

Reach for ConstructorParameters when the class is non-generic
and you are writing a wrapper that forwards arguments without
caring about the resulting instance type. Use InstanceType when
the constructor is hidden behind a factory and you want the
shape of the produced object. Mix them and the errors get
cryptic. InstanceType on a factory function fails because it
requires a constructor signature, not a call signature.
ConstructorParameters on a class returned by a generic factory
loses the generic parameters entirely.

The handbook's utility types
page

is honest that these only work on class constructors. Function
factories that return instances need their own type extraction.

Trap 4: Awaited<T> recursion bottoms out as unknown

The recursive unwrap that makes Awaited correct in the common
case becomes a foot-gun in the generic case. The shape:

async function pipeline<T>(
  input: T,
  steps: Array<(x: unknown) => Promise<unknown>>,
): Promise<T> {
  let cur: unknown = input;
  for (const step of steps) {
    cur = await step(cur);
  }
  return cur as T;
}

type Result = Awaited<ReturnType<typeof pipeline>>;
//   ^? unknown
Enter fullscreen mode Exit fullscreen mode

The unknown is correct, in the formal sense: pipeline is
generic and the call site has not bound T. The fix is to bind
the generic at the extraction site:

type Result = Awaited<ReturnType<typeof pipeline<User>>>;
//   ^? User
Enter fullscreen mode Exit fullscreen mode

That syntax landed in TypeScript 5.2
as type parameter instantiation expressions. Before 5.2, you
had to write a wrapper function and call ReturnType on the
wrapper, which was the kind of indirection that made teams stop
caring about the type and just as-cast the result.

The deeper recursion limit shows up with promise-of-promise
chains:

// pseudocode — substitute a [Subtract, N] decrement helper
type Deep<N extends number> = N extends 0
  ? "done"
  : Promise<Deep<DecrementOf<N>>>;
Enter fullscreen mode Exit fullscreen mode

A shallow Awaited<Deep<...>> resolves cleanly. A deep one will
hit the TypeScript instantiation depth
limit
(the
compiler caps recursive type instantiation; the exact cap has
changed across versions) and emit "Type instantiation is
excessively deep and possibly infinite." Real code does not stack
that many promises by hand, but generated types from GraphQL
resolvers, tRPC routers, and ORM relations sometimes do. The fix
at the source is to flatten the generated type one layer (replace
Promise<Promise<T>> with the single-promise shape the runtime
actually sees). The fix at the consumer is to declare the leaf
type explicitly and use it as the alias instead of letting
Awaited recurse.

The one rule that prevents three of these

The bugs in traps 1, 3, and 4 share a root: pulling a type out of
a typeof someValue expression without asking whether the
generic parameters, the async-ness, or the overload set were part
of the answer.

The rule is: when the function or class is generic, async, or
overloaded, do not extract its type with the bare utilities.
Re-declare the signature you want, or thread the generics
through, or use Awaited<ReturnType<typeof f>> for async, or
write the conditional-inference helper for overloads. The
utility types are for the simple case: synchronous, monomorphic,
single-overload functions and non-generic classes. Three of them
have escape hatches. One of them (Parameters on overloads)
does not, and that is a language-level shape the type system has
decided not to expose.

The fourth trap is different. Awaited's recursion is correct
and useful, and the unknown it falls back to is the compiler
telling you it ran out of information. The fix there is to give
it more information, not to work around the type.

When to use these utilities at all

Two clear wins, and one clear loss.

The first win: extracting types from a third-party library where
the function signature is the source of truth and you do not
control the declaration. Parameters<typeof express.Router.use>
is a fine way to mirror an Express handler signature without
copying it.

The second win: forwarding shapes through a small wrapper that
adds logging, retry, or instrumentation around an existing call.
The wrapper does not need to re-declare the signature; the
utility types make the wrapper's signature track the inner
function's shape.

The loss: building public-API types out of utility-type
expressions. If your library exposes a generic, your users will
read your .d.ts to understand what they can pass and what they
get back. A return type written as
Awaited<ReturnType<typeof internalFn>> shows the user the
internal function's existence and asks them to chase types
through your internals. Re-declare the public type. The
utilities are for the consumer; the producer writes the type
directly.

Audit your codebase for ReturnType<typeof on async functions
tomorrow morning. The other three traps you will catch as you
find them. The autocomplete will hand you the wrong utility. The
compiler will let it slide far enough that the bug ships.


If this was useful

The four traps above are the entry-level versions of the
extraction-and-composition patterns The TypeScript Type System
spends a chapter on each. The book takes the same posture this
post does: the utility types are a starting point, not a
finished vocabulary, and the senior-grade move is knowing when
the autocomplete is leading you toward the right keyword for the
wrong shape. infer, conditional types, and template literal
types are the primitives the utilities are built from, and the
chapter on overloads has the depth-N helper expanded out so you
do not have to copy it from a stackoverflow answer that is older
than your codebase.

If you are coming to the type system from runtime-first
TypeScript, TypeScript Essentials is the book that pairs with
this one: the runtime feature set, the narrowing rules, and the
build configurations across Node, Bun, Deno, and the browser.
TypeScript in Production picks up where both end, with the
library-authoring decisions that make extracted types stable
across versions of the function they came from.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)