Back to TypeScript
TypeScript
medium
senior

How do generics, utility types, and narrowing work together in TypeScript?

Generics parameterize types over types — use them for collections, hooks, and API wrappers. Utility types (`Partial`, `Pick`, `Omit`, `Record`, `ReturnType`, etc.) transform existing types instead of redeclaring them. Narrowing turns broad unions into specific types via `typeof`, `in`, discriminated unions, and user-defined type predicates. Together they let one function safely serve many shapes.

9 min read·~18 min to think through

Three tools that together cover ~80% of practical TypeScript beyond plain types.

Generics

Parameters for types. The function/class/type stays generic until called.

ts
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
first([1, 2, 3]);     // T inferred as number → returns number | undefined
first(["a", "b"]);    // T inferred as string → returns string | undefined

Constraints.

ts
function getId<T extends { id: string }>(x: T) { return x.id; }
getId({ id: "a", name: "b" }); // ok
getId({ name: "b" });           // error

Default parameters.

ts
function fetcher<T = unknown>(url: string): Promise<T> { ... }
fetcher<User>("/me");
fetcher("/me");  // T = unknown

Higher-kinded patterns (currying through types).

ts
type ApiClient<Schemas> = {
  [K in keyof Schemas]: (input: Schemas[K]) => Promise<unknown>;
};

Where you reach for generics in practice. Anything reusable across types: hooks (useState<T>), data fetchers, validators, list/map utilities, component props that accept arbitrary item types.

Utility types

Built-ins that transform types. Memorize the seven that come up daily:

ts
Partial<T>        // all properties optional
Required<T>       // all properties required
Readonly<T>       // all properties readonly
Pick<T, K>        // subset by key
Omit<T, K>        // T minus K
Record<K, V>      // { [P in K]: V }
ReturnType<F>     // F's return type

Plus, less daily but high-value:

ts
Parameters<F>            // tuple of F's parameter types
NonNullable<T>           // exclude null | undefined
Awaited<T>               // unwrap Promise<T>
Extract<T, U>            // keep members of T that extend U
Exclude<T, U>            // drop members of T that extend U
InstanceType<C>          // instance type of a constructor

Build your own with mapped + conditional types.

ts
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

type NonReadonly<T> = { -readonly [K in keyof T]: T[K] };

type ValuesOf<T> = T[keyof T];

The -readonly and -? modifiers in mapped types are how you remove modifiers.

Narrowing

Turn a union into a specific branch.

typeof for primitives.

ts
function fmt(x: string | number) {
  if (typeof x === "string") return x.toUpperCase(); // narrowed to string
  return x.toFixed(2);                                // narrowed to number
}

in for object shape.

ts
function area(s: { width: number; height: number } | { radius: number }) {
  return "radius" in s ? Math.PI * s.radius ** 2 : s.width * s.height;
}

Discriminated unions — the senior pattern.

ts
type Result<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(r: Result<User>) {
  switch (r.status) {
    case "loading": return <Spinner />;
    case "success": return <Profile user={r.data} />;     // narrowed
    case "error":   return <Msg text={r.error.message} />; // narrowed
  }
}

The literal-string status is the discriminant; TS uses it to narrow each branch automatically.

Exhaustive checks with never.

ts
function assertNever(x: never): never { throw new Error("unhandled"); }

switch (r.status) {
  case "loading": ...
  case "success": ...
  case "error":   ...
  default: assertNever(r);  // compile error if a new variant is added
}

Adding a new variant to Result will cause TS to error here — guaranteed forward-compat.

User-defined type predicates.

ts
function isUser(x: unknown): x is User {
  return !!x && typeof (x as User).id === "string";
}

if (isUser(blob)) { blob.id; } // narrowed

Use sparingly — they're "trust me" assertions. Use Zod / Valibot for runtime validation that also narrows at the type level.

asserts keyword.

ts
function assertDefined<T>(x: T): asserts x is NonNullable<T> {
  if (x == null) throw new Error("nope");
}

assertDefined(user);
user.email; // narrowed to non-null

Putting them together

ts
type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };

async function api<T>(url: string): Promise<ApiResult<T>> {
  try {
    const data = (await fetch(url).then(r => r.json())) as T;
    return { ok: true, data };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
}

const res = await api<User>("/me");
if (res.ok) {
  console.log(res.data.email);   // narrowed
} else {
  console.error(res.error);
}

Generic over the response shape, returns a discriminated union, caller narrows. The same pattern shows up in TanStack Query results, server actions, and any well-typed API client.

Senior framing. Generics are how you avoid any. Utility types are how you avoid copy-pasting. Narrowing is how you avoid as casts. Reach for all three before reaching for any or as unknown as ... — those should be last-resort, with comments explaining why.

Follow-up questions

  • Why prefer discriminated unions over optional fields?
  • When does TS lose narrowing across closures?
  • How would you build a `DeepReadonly` utility type?
  • What does `infer` do in a conditional type?

Common mistakes

  • Using `any` when a generic would have inferred correctly.
  • Casting with `as` instead of narrowing.
  • Building deep utility types without recursion guards (depth limits in TS).
  • Forgetting `asserts` requires an explicit return type annotation.

Performance considerations

  • Heavy conditional/mapped types slow the type checker — measure with `tsc --extendedDiagnostics`.
  • Inference works best when generic parameters are on inputs, not return types alone.

Edge cases

  • Closures over narrowed variables lose narrowing — assign to a `const` first.
  • `typeof null === "object"` — check with `=== null` first.
  • Generic functions returning `T | undefined` — TS may widen too aggressively without explicit annotation.

Real-world examples

  • TanStack Query's hook signatures, Zod's `z.infer`, tRPC's end-to-end inference.

Related questions