TypeScript: generics, utility types, narrowing
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.
Three tools that together cover ~80% of practical TypeScript beyond plain types.
Generics
Parameters for types. The function/class/type stays generic until called.
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 | undefinedConstraints.
function getId<T extends { id: string }>(x: T) { return x.id; }
getId({ id: "a", name: "b" }); // ok
getId({ name: "b" }); // errorDefault parameters.
function fetcher<T = unknown>(url: string): Promise<T> { ... }
fetcher<User>("/me");
fetcher("/me"); // T = unknownHigher-kinded patterns (currying through types).
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:
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 typePlus, less daily but high-value:
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 constructorBuild your own with mapped + conditional types.
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.
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.
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.
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.
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.
function isUser(x: unknown): x is User {
return !!x && typeof (x as User).id === "string";
}
if (isUser(blob)) { blob.id; } // narrowedUse sparingly — they're "trust me" assertions. Use Zod / Valibot for runtime validation that also narrows at the type level.
asserts keyword.
function assertDefined<T>(x: T): asserts x is NonNullable<T> {
if (x == null) throw new Error("nope");
}
assertDefined(user);
user.email; // narrowed to non-nullPutting them together
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.