Skip to content
Chapter 5Lesson 7

Generics with constraints

Writing your own generic functions and types in TypeScript, and the extends constraints that let one signature carry a precise type from the call site to the return.

You’ve spent six lessons reading generics like Result<T> and the utility types without ever writing one. This lesson hands you the tool for authoring your own. Start with the bug it fixes.

const identity = (value: unknown): unknown => value;
const result = identity(42);
const next = result + 1;
// ~~~~~~
// 'result' is of type 'unknown'.

The function returns whatever you pass it, but the signature says unknown in and unknown out, discarding the type the call site had. The compiler can’t connect argument to return, so result + 1 fails: unknown isn’t a number.

That symbol is a generic , and <T> is its type parameter , filled in by the call site. The rest of the lesson builds toward the wrapper shapes that carry real weight in 2026 web code: safeAction, requireRole, a memoize-shape function preserver, and the pluck signature that already lives inside Pick and Omit.

This is the shape worth memorizing, shown on the same identity function in its three parts.

const identity = <T>(value: T): T => value;
const n = identity(42);
const s = identity('hello');
const u = identity<User>(currentUser);

The <T> just before the parameter list declares a type parameter named T. You can reference T anywhere in the signature and the body; here it’s both the parameter type and the return type. The convention is single-letter names: T for any type, K for key, V for value, R for return, and P for parameters. Longer names like TArgs exist, but these single letters are what every TypeScript reader expects.

const identity = <T>(value: T): T => value;
const n = identity(42);
const s = identity('hello');
const u = identity<User>(currentUser);

The compiler reads the argument and fills in T for you. identity(42) infers T = number, so the return type is number; identity('hello') infers T = string. The call site supplies the type implicitly, so let inference do the work whenever it can.

const identity = <T>(value: T): T => value;
const n = identity(42);
const s = identity('hello');
const u = identity<User>(currentUser);

Sometimes inference can’t help, because the argument is unknown or no value parameter pins T. Then supply the type argument explicitly: identity<User>(currentUser). This is the exception. If you write it on every call, the function is probably missing a parameter that should have constrained T.

1 / 1

One rule worth stating plainly: the type parameter is a type, not a value. TypeScript erases it at compile time, so T has no runtime representation and you can’t read it inside the function body. It’s the same type-level versus value-level distinction from the “Derive types from values” lesson.

The same tool works on type aliases. Take the Result<T> shape from the first lesson of this chapter and write it as the form you’d author yourself:

type Result<T, E = AppError> =
| { ok: true; data: T }
| { ok: false; error: E };

Three things to notice. The <T, E = AppError> after the alias name declares the type parameters, with the same syntax and scoping as on a function. The right-hand side references both by name, so Result<User> resolves to { ok: true; data: User } | { ok: false; error: AppError }, and Result<User, ValidationError> swaps ValidationError into the error variant.

The E = AppError is a default type parameter : Result<User> is valid shorthand because E falls back to AppError, and Result<User, ValidationError> overrides it. Type-level defaults earn their place the same way value-level defaults do, a short call site for the common case and an override for the rest. The same tail rule applies, so <T = string, E> is rejected. (AppError is this course’s canonical error shape, defined concretely in the chapter on Server Actions.)

Constraints are what let a generic function’s body do real work. To the function body, an unconstrained T is effectively unknown: the body can’t know T’s shape, so it can only run operations that work on every type, which is almost none. Try anything specific and the compiler stops you:

const firstChar = <T>(value: T): string => {
return value.charAt(0);
// ~~~~~~
// Property 'charAt' does not exist on type 'T'.
};

The compiler is right: T could be a number, a boolean, or an object, none of which have charAt. The fix is to name what the function needs from T:

const firstChar = <T>(value: T): string => {
return value.charAt(0);
// ~~~~~~
// Property 'charAt' does not exist on type 'T'.
};

An unconstrained T admits every type, so the compiler can’t assume charAt exists and rejects the call.

Write the constraint from the first keystroke: an unconstrained generic is usually a missing constraint, not a starting point you tighten later.

Three constraint shapes carry most of the work.

T extends string (and other primitive constraints)

Section titled “T extends string (and other primitive constraints)”

This accepts only string-assignable types, so it fits any helper that takes string-shaped input, including the branded IDs from the “Branded IDs” lesson. A UserId is structurally string & { __brand: 'UserId' }, so it satisfies T extends string and keeps its brand at the return site.

const slugify = <T extends string>(value: T): string =>
value.toLowerCase().replace(/\s+/g, '-');

The same works with T extends number or any other primitive, but T extends string is by far the most common in app code.

T extends Record<string, unknown> (and other object shapes)

Section titled “T extends Record<string, unknown> (and other object shapes)”

This accepts any object. Reach for it in any helper that enumerates fields or spreads the input, the canonical case being a defaults merger:

const withDefaults = <T extends Record<string, unknown>>(
input: Partial<T>,
defaults: T,
): T => ({ ...defaults, ...input });

The constraint says “give me an object I can spread”; the body spreads it. The caller supplies any concrete object shape, and the return type matches.

K extends keyof T, the load-bearing constraint

Section titled “K extends keyof T, the load-bearing constraint”

This is the one to know cold. The signature pluck<T, K extends keyof T>(obj: T, key: K): T[K] is the most useful generic shape in app TypeScript: every “read one field of a caller-supplied object” helper has it. Pick<T, K> and Omit<T, K> use the exact same constraint, so once you can write pluck, those utility-type definitions become mechanical.

Here is the signature:

const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
const u = { id: 'u_1' as UserId, name: 'Ada', age: 30 };
const id = pluck(u, 'id'); // UserId
const name = pluck(u, 'name'); // string
const age = pluck(u, 'age'); // number

Two type parameters, declared in order: T for the object type, K for the key. The second is constrained by K extends keyof T, so K must be one of T’s keys. If T is { id: UserId; name: string; age: number }, then keyof T is 'id' | 'name' | 'age', and K must be one of those three literals. The constraint references the previous type parameter, so order matters.

const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
const u = { id: 'u_1' as UserId, name: 'Ada', age: 30 };
const id = pluck(u, 'id'); // UserId
const name = pluck(u, 'name'); // string
const age = pluck(u, 'age'); // number

The value parameters use the type parameters by name: obj is typed T, key is typed K. Note the asymmetry: T infers from obj as the whole object’s type, but K infers from key as the narrow literal 'id', not the broad keyof T union. That narrow K is what makes the precise return type possible.

const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
const u = { id: 'u_1' as UserId, name: 'Ada', age: 30 };
const id = pluck(u, 'id'); // UserId
const name = pluck(u, 'name'); // string
const age = pluck(u, 'age'); // number

The return type uses the indexed-access operator from the “Derive types from values” lesson: T[K] is the type of T at key K. If T is the user shape and K is 'id', T[K] is UserId; if K is 'age', T[K] is number. The return type recomputes at every call site from the key the caller supplied, so one signature yields a different precise return type each time.

const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];
const u = { id: 'u_1' as UserId, name: 'Ada', age: 30 };
const id = pluck(u, 'id'); // UserId
const name = pluck(u, 'name'); // string
const age = pluck(u, 'age'); // number

Read the three calls in turn. pluck(u, 'id') returns UserId, not the broader string | UserId | number; pluck(u, 'name') returns string; pluck(u, 'age') returns number. The branded UserId survives, because the brand lives inside the object and pluck extracts it precisely. One signature, three inferred return types: that is what generics with constraints buy you.

1 / 1

Hover the call sites to see the inferred types:

const id = pluck(u, 'id');
const name = pluck(u, 'name');
const age = pluck(u, 'age');

The same K extends keyof T lives inside Pick<T, K> and Omit<T, K>; you can now read those definitions as the same shape put to a different end.

TypeScript 5.0 added a modifier that fixes a recurring inference problem.

Picture a tabs helper. The caller passes an inline array of tab names, and the helper returns an object including those names. By default ['home', 'about'] widens to string[], because TypeScript treats array literals as mutable. The widening breaks downstream type-level work: a router that wanted a literal union of valid route names gets string instead, and the literals the caller had at the call site are gone.

const tabs = <T extends readonly string[]>(values: T): { values: T } => ({ values });
const t = tabs(['home', 'about']);
// ^? { values: string[] }

Without const, the inline array widens to string[] and T is inferred as string[]. The literals, that the array held exactly 'home' and 'about', are gone, so downstream code can only derive string, not a literal union.

Reach for <const T> when the wrapper’s downstream consumer needs the literal types: a router deriving route names, a permissions helper deriving a role union, a tabs helper deriving a key-of-tabs union. The wrapper does the as const work so the caller never has to remember to.

The const type parameter modifier is permitted only on functions, methods, and classes; the compiler rejects it on a generic type alias or interface.

This pairs with the typeof ARR[number] derivation from the “Derive types from values” lesson: a wrapper that accepts <const T extends readonly string[]> and returns a shape using T[number] preserves the literals and hands the derived union downstream.

These three wrappers show up in every SaaS codebase the course builds toward. The bodies come in later chapters; what matters now is reading each signature and defending it in code review, knowing which type parameter does what.

Server Actions need a uniform shape: parse the input, run the handler, and return a Result<T>. The wrapper ties three types together: the Zod schema, the validated input the handler receives, and the value the handler returns.

const safeAction = <Schema extends z.ZodType, Output>(
schema: Schema,
handler: (input: z.infer<Schema>) => Promise<Output>,
) => async (input: unknown): Promise<Result<Output>> => {
const parsed = schema.safeParse(input);
if (!parsed.success) return { ok: false, error: toAppError(parsed.error) };
try {
return { ok: true, data: await handler(parsed.data) };
} catch (cause) {
return { ok: false, error: ensureAppError(cause) };
}
};

<Schema extends z.ZodType, Output> constrains Schema to a Zod schema and leaves Output unconstrained for the handler’s return. z.infer<Schema> then derives the handler’s input type from the schema, so the handler receives a precisely typed input you never write by hand, and the wrapper returns Result<Output>, threading that return into the success variant of Result.

The caller’s view stays clean:

export const createInvoice = safeAction(
createInvoiceSchema,
async (input) => db.insert(invoices).values(input).returning(),
);

The caller supplies the schema and the handler and gets back a function that validates, runs, and returns Result<...>, with no annotations to write: the generics carry the types from the schema through to the result.

This is the shape that runs in front of every protected action. The wrapper ties the required role literal to the context shape the handler receives: if the caller required 'owner', the handler’s ctx.role is the literal 'owner', not the broad role union.

const requireRole = <R extends Role, Output>(
role: R,
handler: (ctx: ActionCtx<R>) => Promise<Output>,
): (() => Promise<Output>) => async () => {
const ctx = await getActionCtx();
if (!ctx.roles.includes(role)) throw new ForbiddenError(role);
return handler(ctx as ActionCtx<R>);
};

<R extends Role, Output> constrains R to a member of the value-derived Role union from the “Derive types from values” lesson and leaves Output unconstrained. ActionCtx<R> is itself generic, and its role field narrows to whatever literal R the caller passed, so downstream code reads the exact role instead of the broad union. This is why the value-derived Role and the <const T> modifier are paired tools.

export const exportBilling = requireRole('owner', async (ctx) => {
// ctx.role is 'owner', not Role
return generateBillingExport(ctx.orgId);
});

The third pattern is the one every cache, retry, and decorator wrapper uses: take any function and return one with the same call signature, the same arguments, return type, and call-site hints.

const memoize = <P extends unknown[], R>(
fn: (...args: P) => R,
): ((...args: P) => R) => {
const cache = new Map<string, R>();
return (...args: P): R => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key) as R;
};
};

<P extends unknown[], R> is the spread-parameters idiom. P extends unknown[] captures any function’s parameters as a tuple, (...args: P) consumes that tuple as a rest parameter, and the returned function spreads it back out, so the wrapped function keeps the input’s exact call shape and IDE hints. You’ll see it in every logger decorator, profiler hook, and third-party type that wraps a function without losing its signature.

Don’t ship this body. The course’s production caching tool is Next.js 16’s 'use cache' directive, covered later; this memoize is a teaching vehicle for the <P extends unknown[], R> pattern. Its JSON.stringify(args) key is fragile: it throws on circular refs, silently drops undefined, and doesn’t guarantee key ordering across object shapes.

Generic features you’ll read but not write

Section titled “Generic features you’ll read but not write”

A handful of generic features belong to library authors. You’ll read them in framework and .d.ts types but won’t write them in app code: conditional types (T extends U ? X : Y) and infer, mapped types as transforms ({ [K in keyof T]: ... }), NoInfer<T>, higher-kinded types, generic classes, and function overloads. Variance belongs to the same layer.

This is the lesson’s central exercise. Write the signature you just walked through; the type-checker confirms each call narrows to the right type.

Type pluck so each call site returns the precise type of the field. The three ^? queries should resolve to UserId, string, and number. The @ts-expect-error directive at the bottom should hold — meaning the line below it (the call with an invalid key) must actually fail to compile.

  • Type query at line 12 must resolve to a type containing UserId
  • Type query at line 14 must resolve to a type containing string
  • Type query at line 16 must resolve to a type containing number
Booting type-checker…
Reveal the reference solution
const pluck = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];

<T, K extends keyof T> declares two type parameters: T for the object, K constrained to a key of T. The value parameters use them by name (obj: T, key: K), and the return type is the indexed access T[K]. At each call site K narrows to the literal key passed ('id', 'name', 'age'), so the return recomputes as UserId, string, and number. The invalid 'email' key fails the keyof T constraint, which is what makes @ts-expect-error hold.

Every other wrapper in this course leans on this same K extends keyof T pattern.

Four places to go deeper on the patterns this lesson surfaces.

The chapter ends here; the next one takes the same TypeScript floor and builds modules on top of it.