By Ryan Calloway. Updated May 2026.
The recurring r/typescript “do people think TypeScript is hard?” threads land on the same conclusion every quarter: the language is fine, complex generics are where teams hit the wall. Tech leads counter with “I do not put up with overly complicated or needlessly complex types.” Both are right. Seven patterns cover almost every generic that earns its keep in application code; the rest is library work that you read but rarely write. This guide walks through the seven, in TypeScript 5.7 syntax, with the --noUncheckedIndexedAccess and satisfies operator usage that has become standard since the 5.0 line.
What you’ll learn
- The minimum mental model — read any generic signature left to right.
- The seven patterns that show up in real codebases: generic constraints, conditional types,
infer, mapped types, template literal types, distributive conditional types, and a generic React component. - TypeScript 5.7-specific helpers:
satisfies, const type parameters, and why--noUncheckedIndexedAccesschanges the rules. - The three mistakes that show up in every learning codebase.
Prerequisites
TypeScript 5.0+ (5.7 is current as of November 2024); a project with "strict": true in tsconfig.json and ideally "noUncheckedIndexedAccess": true. The seven examples assume Node 22 LTS or any evergreen browser. The mental shortcut: a generic is a parameter that stands for a type, not a value. function first<T>(arr: T[]): T reads “give me an array of any type T, I give you back a single element of that same T.” The compiler substitutes T for the caller’s real type at each call site.
Pattern 1: generic constraints with extends
Plain T is too loose for most code. You usually want “any type that has a .length” or “any string-keyed object.” That is what extends is for.
// Before: untyped, error-prone
function longest(a: any, b: any) {
return a.length >= b.length ? a : b;
}
// After: constrained generic, full type safety
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("ada", "grace"); // ok: strings have .length
longest([1, 2], [3, 4, 5]); // ok: arrays have .length
longest(1, 2); // Error: number has no .length
The most useful constraint shape is K extends keyof T, used for type-safe property access:
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 212 };
pick(user, "name"); // string
pick(user, "age"); // number
pick(user, "missing"); // Error: "missing" is not a key of user
The full grammar lives in the TypeScript handbook on generics.
Pattern 2: conditional types
Conditional types are if-statements at the type level. Syntax: T extends U ? X : Y.
// Before: hand-maintained union
type StringOrNumber = string | number;
// After: derived from input
type Stringify<T> = T extends number ? `${T}` : T extends boolean ? `${T}` : T;
type A = Stringify<42>; // "42"
type B = Stringify<true>; // "true"
type C = Stringify<"hi">; // "hi"
The real power shows up when T is generic and the type author does not know what concrete type the caller will pass. The TypeScript stdlib’s NonNullable<T>, Exclude<T, U>, and Extract<T, U> are all conditional types — read the utility types reference to see how a few primitives compose into the standard library you already use.
Pattern 3: infer — extract a type from a position
infer destructures a type. Inside a conditional, you can name a slot and get its type back.
// Extract the resolved value of a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
type X = Awaited<Promise<number>>; // number
type Y = Awaited<Promise<Promise<string>>>; // Promise<string> (only one level)
type Z = Awaited<number>; // number (passes through)
// Extract the return type of a function
type ReturnOf<F> = F extends (...args: any[]) => infer R ? R : never;
function loadUser(id: string) {
return { id, name: "Ada" };
}
type User = ReturnOf<typeof loadUser>; // { id: string; name: string }
The standard library ships Awaited<T>, ReturnType<F>, Parameters<F>, and InstanceType<C>; all four are infer in three lines or fewer. The handbook section on inferring within conditional types covers the variance rules that bite around overloads.
Pattern 4: mapped types
Mapped types build a new type by walking the keys of an existing one and rewriting each field.
// The pattern: { [K in keyof T]: NewType<T[K]> }
type Nullable<T> = { [K in keyof T]: T[K] | null };
type ReadonlyDeep<T> = { readonly [K in keyof T]: T[K] };
type StringValues<T> = { [K in keyof T]: string };
type User = { id: number; email: string; verified: boolean };
type DraftUser = Nullable<User>;
// { id: number | null; email: string | null; verified: boolean | null }
TypeScript’s built-in Partial<T>, Required<T>, Readonly<T>, Pick<T, K>, and Record<K, T> are mapped types in three lines. The + and - modifiers add or remove the readonly and ? markers per key:
// Strip readonly from every field
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
// Make every field required (strip ?)
type Required<T> = { [K in keyof T]-?: T[K] };
Mapped types are also where you implement form-library types like { [K in keyof Form]: FieldState<Form[K]> } — see how react-hook-form and zod derive runtime validators from a single source-of-truth schema.
Pattern 5: template literal types
Template literal types are JavaScript’s template literal syntax, but at the type level. Combined with mapped types, they become surprisingly capable.
// Build URL routes from a base
type ApiPath = `/api/${string}`;
const ok: ApiPath = "/api/users"; // ok
const bad: ApiPath = "/users"; // Error
// Mirror REST verbs
type Verb = "get" | "post" | "put" | "delete";
type Endpoint<T extends string> = `${Verb} ${T}`;
type UserEndpoint = Endpoint<"/users">;
// "get /users" | "post /users" | "put /users" | "delete /users"
// Auto-generate event handler names from event names
type Events = "click" | "focus" | "blur";
type Handlers = { [E in Events as `on${Capitalize<E>}`]: (e: Event) => void };
// { onClick: (e: Event) => void; onFocus: ...; onBlur: ... }
The four built-in intrinsic helpers — Uppercase<S>, Lowercase<S>, Capitalize<S>, Uncapitalize<S> — let you transform string types. The combination of template literals + mapped types + key remapping (as) is how libraries like tRPC derive client method names from server route definitions; the type magic is more readable than it looks.
Pattern 6: distributive conditional types
When a conditional type checks a naked type parameter against a union, TypeScript distributes the check across each member of the union.
// T = "a" | "b" | "c"
type Wrap<T> = T extends string ? { value: T } : never;
type W = Wrap<"a" | "b" | "c">;
// = Wrap<"a"> | Wrap<"b"> | Wrap<"c">
// = { value: "a" } | { value: "b" } | { value: "c" }
Distribution is the difference between “transform the union” (usually what you want) and “transform the union as a whole” (rarely). The [T] extends [U] tuple-wrap trick disables distribution when you do not want it:
// Distributive: applies per member
type IsString<T> = T extends string ? true : false;
type A = IsString<string | number>; // true | false
// Non-distributive: applies to the whole union
type IsStringStrict<T> = [T] extends [string] ? true : false;
type B = IsStringStrict<string | number>; // false
This is the most common source of “why is my generic returning never sometimes?” bug reports. Distribution silently drops the never branch, so a union with one member that fails the check has the failing member dropped. Useful when intentional, surprising when not. The handbook section on distributive conditional types is worth a focused read.
Pattern 7: a generic React component (the one that pays back hardest)
The hooks where generics matter most: anything that takes data of an unknown shape from outside and exposes it typed. useFetch<T>, useLocalStorage<T>, useQuery<T>, and a generic <Select<T>> component.
// Generic hook: caller specifies the response shape
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
.then((r) => r.json() as Promise<T>)
.then(setData, setError)
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [url]);
return { data, loading, error };
}
type User = { id: string; email: string };
const { data } = useFetch<User>("/api/me");
data?.email; // autocompletes to User.email
The same pattern applied to a UI component, with a constraint that pins the option shape:
type SelectProps<T extends { id: string; label: string }> = {
options: T[];
value: T["id"] | null;
onChange: (item: T) => void;
};
function Select<T extends { id: string; label: string }>({
options,
value,
onChange,
}: SelectProps<T>) {
return (
<select
value={value ?? ""}
onChange={(e) => {
const found = options.find((o) => o.id === e.target.value);
if (found) onChange(found);
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
);
}
Now onChange hands back the full T (not just an id), and the consumer keeps any extra fields they put on the option type. The React TypeScript guide covers the patterns; Vue’s equivalent — defineComponent<Props> with generic="T" on <script setup> — is in the Vue 3.3+ composition API docs.
TypeScript 5.7 specifics worth knowing
--noUncheckedIndexedAccess. Off by default; turn it on. With it,arr[0]isT | undefinedinstead ofT, which forces you to handle the empty-array case before crashing at runtime. The recurring “TypeScript said it was a string, but it was undefined” bug class disappears.- The
satisfiesoperator (5.0+).const config = { ... } satisfies Configvalidates the value againstConfigwithout widening — you keep the narrowest inferred type. Replaces the commonascast that silently weakened types.type Theme = "light" | "dark"; const t = "light" satisfies Theme; // type is "light", not Theme consttype parameters (5.0+).function f<const T>(x: T)infers the literal type at the call site without callers writingas const. Useful for builders that capture exact tag strings.- Decorator metadata (5.2+). Standard JS decorators land with metadata support; ORMs and validation libraries are migrating off the experimental flavor. If you started a project before 2024, your old
experimentalDecoratorscode keeps working but is no longer the recommended path.
The full release notes for each minor version live on the TypeScript dev blog; the 5.7 announcement covers the path-rewriting and --strictBuiltinIteratorReturn additions.
Three mistakes in every learning codebase
- Using
anywhere a generic fits.function first(arr: any[]): anyerases all type information.function first<T>(arr: T[]): Tis the same character count and infinitely more useful. - Over-constraining.
T extends number | stringwhenT extends stringis what you actually need. Start looser; tighten when the body fails to type-check. - Generics that do not link two positions in the signature. If
Tappears exactly once, you do not need a generic — you need a parameter of that type.function log<T>(x: T): voidis justfunction log(x: unknown): voidin disguise. The “is this generic doing work?” test: a generic earns its place when it links input to output, parameter to parameter, or input to a constraint.
FAQ
What does T extends X mean?
“T is some type assignable to X.” It constrains which concrete types the caller can supply. Inside the function, the compiler treats T as at least as specific as X, so any property of X is available on values of type T.
Why does my generic infer unknown?
Because the compiler cannot pick a narrower type from the context. The argument is empty, the function is used in a position the compiler cannot relate to a concrete type, or you wrote a generic that does not link to any value-level argument. Pass an explicit type: fn<string>(...).
Can I default a generic parameter?
Yes: function fetch<T = unknown>(url: string): Promise<T>. Defaults follow the same rules as function-parameter defaults — they kick in when the caller omits the argument. T = unknown is the safest default; it forces narrowing before use.
Is Array<T> the same as T[]?
Identical. T[] is the shorthand. Pick one style per codebase. T[] reads cleaner for simple arrays; Array<T> reads cleaner when T is itself a complex expression because the brackets stay readable.
When should I use satisfies instead of as?
Almost always. as weakens the type to whatever you cast to and silences errors; satisfies validates the value against the type without widening. Reach for as only when you have type information the compiler cannot derive (DOM element types from querySelector, JSON-parsed data after validation).
What is the difference between any and unknown?
any opts out of the type system. unknown opts in: you must narrow it (with typeof, instanceof, or custom guards) before you can use it. Always prefer unknown where you used to reach for any.
Sources and further reading
- TypeScript handbook — Generics
- TypeScript handbook — Conditional types (incl.
infer, distribution) - TypeScript handbook — Mapped types
- TypeScript handbook — Template literal types
- TypeScript handbook — Utility types
- TypeScript 5.7 release notes
- React + TypeScript guide
Next steps
If you have not enabled strictNullChecks and noUncheckedIndexedAccess, do that first; the Cannot read properties of undefined fix covers the bug class they eliminate. For interview-level patterns built on these primitives, JavaScript interview questions 2026 covers the generics-heavy questions that show up on real screens. For Promise-typed generics specifically, see async/await vs Promises.