Generics with constraints
Writing your own generics in TypeScript, with the extends constraint that lets one function signature carry a precise type from the call site through to the return.
You’ve spent six lessons reading generics without ever being asked to write one. Result<T> carried the success-or-failure shape from the “Impossible states, unrepresentable” lesson at the start of this chapter. Brand<T, Name> would have generalized the brand factory from the “Branded IDs” lesson if you’d needed it to. Pick<T, K>, Omit<T, K>, Partial<T>, Awaited<T>, and ReturnType<F> carried the utility-type toolbox from the last lesson. That’s seven generics, all consumed and none authored. This lesson hands you the tool for writing your own, along with the four shapes you’ll actually ship.
Begin with the bug that writing a generic 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 give it, but the type signature doesn’t say so. It says unknown in and unknown out, throwing away the type information the call site had. The compiler can’t connect what went in to what came back, so result + 1 fails because unknown isn’t a number. The link between argument and return is gone.
const identity = <T>(value: T): T => value;
const result = identity(42);const next = result + 1;Adding the single symbol <T> carries the type through. The compiler reads the argument 42 at the call site, fills T with number, and the return type becomes number. Now the call site has the type information it needs, so result + 1 compiles. The function body didn’t change; only the signature did.
That symbol is a generic : a function or type that accepts a type as a parameter. The <T> is the type parameter , and the call site fills it in. The rest of this lesson covers the shapes you’ll write in practice, the wrapper forms that carry real weight in 2026 SaaS code: safeAction for Server Actions, requireRole for authorization, a memoize-shape function preserver, and the pluck signature that already lives inside Pick and Omit. The library-author layer above these is conditional types, infer, and mapped-type authoring. The lesson names that layer once at the end and otherwise leaves it out.
One note on where this leads. The three production wrappers the rest of the course leans on are all generic functions: safeAction in the chapter on Server Actions, requireRole in the chapter on RBAC, and cache-style memoizers in the chapters on caching and rate limiting. This lesson is the floor they sit on.
Generic functions
Section titled “Generic functions”This is the minimum shape you’ll want to recall from memory. Here it is on the same identity function, broken into the three parts that matter.
const identity = <T>(value: T): T => value;
const n = identity(42);const s = identity('hello');const u = identity<User>(currentUser);The <T> in angle brackets, just before the parameter list, declares a type parameter named T. You can reference T anywhere in the signature and the body; here it appears once as the parameter type and once as the return type. The convention is single-letter names: T for “any type,” K for “key type,” V for “value type,” R for “return type,” and P for “parameters.” Some style guides accept longer names like TArgs and TReturn, but this course uses the single letters because every TypeScript reader recognizes them.
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 T in for you. identity(42) infers T = number, so the return type is number. identity('hello') infers T = string, so the return type is string. You don’t write the type yourself; the call site supplies it implicitly. The habit to build is to 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 is wrong or ambiguous, because the argument is unknown or the function has no value parameter that can pin T. In those cases, supply the type argument explicitly in angle brackets at the call site: identity<User>(currentUser). This is the exception, not the default. If you find yourself writing the type argument on every call, the function probably has a missing parameter that should have constrained it.
One rule is worth stating plainly: the type parameter is a type, not a value. TypeScript erases it at compile time, so there is no runtime representation of T. You can’t reach for T as a value inside the function body, because it doesn’t exist when the code runs. This is the same type-level versus value-level distinction from the last lesson.
Generic types
Section titled “Generic types”The same tool works on the type-alias side. Take the Result<T> shape from the first lesson of this chapter and rewrite it as the form you’d author yourself:
type Result<T, E = AppError> = | { ok: true; data: T } | { ok: false; error: E };There are three things to notice. The <T, E = AppError> after the alias name declares the type parameters, using the same syntax and scoping rules 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> resolves to the same shape with ValidationError swapped in for the error variant.
The E = AppError is a default type parameter . If the caller omits that position, the default fills in. Result<User> is a valid shorthand because E falls back to AppError, and Result<User, ValidationError> is a valid override because the caller filled both positions. You reach for type-level defaults for the same reason you reach for value-level defaults: they keep the call site short for the common case while still allowing an override for the edge case. (AppError is the canonical error shape this course settles on; the chapter on Server Actions gives it a concrete definition.)
The position rules match value parameters too: required positions come before optional ones, and defaults trail. You can’t write <T = string, E>, because a defaulted parameter can’t precede a non-defaulted one. It’s the same rule you already know from values, applied at the type level.
Constraints with extends
Section titled “Constraints with extends”Constraints are what let a generic function’s body do real work. From the function body’s perspective, an unconstrained T is effectively unknown: the body has no idea what shape T is, so the only operations it can safely run are the ones that work on every type, which is almost none. Try to do 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 number, boolean, or an object, and none of those 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. The body can’t safely call charAt because there’s no guarantee T has it, so the compiler rejects the call.
const firstChar = <T extends string>(value: T): string => { return value.charAt(0);};<T extends string> declares a constraint , requiring T to be assignable to string. Inside the function body, the compiler now knows T is a string and lets you call charAt. At the call site, T still narrows to whatever string-shaped type the caller passed, whether that’s a literal 'admin', a branded UserId, or the plain string. The constraint names what the function needs from T and nothing more.
Write that constraint from the first keystroke rather than starting with an unconstrained generic and tightening it later. An unconstrained generic is usually a sign that the constraint is missing, not a reasonable starting point.
Three constraint shapes carry most of the work you’ll write.
T extends string (and other primitive constraints)
Section titled “T extends string (and other primitive constraints)”This accepts only string-assignable types. It’s useful for any helper that operates on string-shaped input, including the branded IDs from the “Branded IDs” lesson, where a UserId is structurally string & { __brand: 'UserId' }. A UserId satisfies T extends string cleanly, so the constraint accepts branded IDs without losing the brand at the return site.
const slugify = <T extends string>(value: T): string => value.toLowerCase().replace(/\s+/g, '-');The same pattern works with T extends number, T extends boolean, or any other primitive, though T extends string is by far the most common one 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. It’s what you reach for in any helper that needs to enumerate fields or spread the input, the canonical example being a defaults merger:
const withDefaults = <T extends Record<string, unknown>>( input: Partial<T>, defaults: T,): T => ({ ...defaults, ...input });The constraint says “I need an object I can spread”; the body does the spreading. 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 2026 app TypeScript. Every “read one field of an object the caller supplied” helper has this shape, and so does every framework’s getValue(obj, key) helper. The utility types from the last lesson, Pick<T, K> and Omit<T, K>, both use this exact constraint inside their definitions. Once you can write it, 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];Walk through it one piece at a 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'); // UserIdconst name = pluck(u, 'name'); // stringconst age = pluck(u, 'age'); // numberThere are two type parameters, declared in order: T for the object type and K for the key type. The second is constrained by K extends keyof T, which means K must be assignable to the union of T’s keys. If T is { id: UserId; name: string; age: number }, then keyof T is 'id' | 'name' | 'age', so K must be one of those three literals. Because the constraint references the previous type parameter, the 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'); // UserIdconst name = pluck(u, 'name'); // stringconst age = pluck(u, 'age'); // numberThe value parameters use the type parameters by name. obj is typed T, whatever object the caller passed, and key is typed K, whatever specific key literal the caller passed. Notice the asymmetry: T infers from obj as the whole object’s type, but K infers from key as the narrow literal 'id', not as the broad keyof T union. That K stays narrow is what makes the precise return type below 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'); // UserIdconst name = pluck(u, 'name'); // stringconst age = pluck(u, 'age'); // numberThe return type uses the indexed-access operator from the last lesson on deriving types from values: T[K] is the type of T at the key K. If T is the user shape and K is the literal 'id', then T[K] is UserId. If K is 'age', T[K] is number. The return type recomputes at each call site based on which key the caller supplied. The function has one signature, but the call site gets a different precise return type every 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'); // UserIdconst name = pluck(u, 'name'); // stringconst age = pluck(u, 'age'); // numberRead the three calls in turn. pluck(u, 'id') returns UserId, not the broader string | UserId | number. pluck(u, 'name') returns string, and pluck(u, 'age') returns number. The branded UserId survives: the brand from the branded-IDs lesson lives inside the object, and pluck extracts it precisely. One signature gives you three different return types, all inferred. That is what generics with constraints buy you.
Hover the call sites to see the inferred types in place:
const id = pluck(u, 'id');const name = pluck(u, 'name');const age = pluck(u, 'age');This connects straight back to the last lesson. The same K extends keyof T constraint lives inside Pick<T, K> and Omit<T, K>, a detail the last lesson named in passing. Now that you can write this signature, you can read those utility-type definitions: they are the same shape, applied to a slightly different end.
The const type parameter modifier
Section titled “The const type parameter modifier”One short detour before the wrappers: a TypeScript 5.0 addition that fixes a narrow but recurring annoyance.
Picture a tabs helper. The caller passes an inline array of tab names, and the helper returns an object that includes those names. By default, the inferred type widens, so ['home', 'about'] becomes string[]. That happens because TypeScript assumes arrays are mutable and lets string literals widen unless told otherwise. The widening breaks downstream type-level work: a router that wanted to derive a literal union of valid route names ends up with string, and the literal information the caller had at the call site is gone.
const tabs = <T extends readonly string[]>(values: T): { values: T } => ({ values });
const t = tabs(['home', 'about']);// ^? { values: string[] }Without const, the inline array argument widens to string[] at the call site. T is inferred as string[], and the literal information, that the array contained exactly 'home' and 'about', is gone. Downstream code can no longer derive a literal union from T; it would just see string.
const tabs = <const T extends readonly string[]>(values: T): { values: T } => ({ values });
const t = tabs(['home', 'about']);// ^? { values: readonly ['home', 'about'] }Add const in front of the type parameter and the compiler infers literal types for T, exactly as if the caller had written as const on the argument. The tuple narrows to readonly ['home', 'about'], so downstream type-level work has the literals to work with: T[number] to derive the union, or indexed access to derive paths.
The rule, plainly stated: reach for <const T> when the wrapper’s downstream consumer needs the literal types. That covers a router that derives route names, a permissions helper that derives a role union, or a tabs helper that derives a key-of-tabs union. Without it, the literal information is lost at the call site. With it, the wrapper does the as const work so the caller never has to remember to.
One scope rule is worth knowing: the const type parameter modifier is permitted only on functions, methods, and classes. The compiler refuses it on a generic type alias or interface, and the diagnostic is clear if you try: “A ‘const’ modifier cannot be used here.” Use it on the function form, and don’t try to layer it onto a type alias.
This composes neatly with the typeof ARR[number] derivation from the last lesson. A wrapper that accepts <const T extends readonly string[]> and returns a shape using T[number] is the canonical “labeled-set” helper: one signature, with the literal narrows preserved and the derived union available downstream.
Three wrappers you’ll review
Section titled “Three wrappers you’ll review”These three wrappers show up in every SaaS codebase the course points at. The bodies aren’t the focus here, since the chapters on Zod and Server Actions, RBAC, and Next.js caching build those out. What matters now is that you can read these signatures and defend them in a code review, knowing exactly which type parameter does what.
safeAction, the Server Action wrapper
Section titled “safeAction, the Server Action wrapper”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) }; }};Focus on the generics in the signature. <Schema extends z.ZodType, Output> constrains Schema to a Zod schema type and declares an unconstrained Output for the handler’s return. z.infer<Schema> then derives the validated input type from the schema and passes it as the handler’s parameter, so the handler receives a precisely typed input without your writing the type by hand. The wrapper’s return is Result<Output>, which threads the handler’s return into the success variant of the Result union from earlier.
The caller’s view stays clean:
export const createInvoice = safeAction( createInvoiceSchema, async (input) => db.insert(invoices).values(input).returning(),);The caller writes the schema and the handler, and gets back a function that validates, runs, and returns Result<...> end to end. No type annotations are needed, because the generics carry the types from the schema through to the result. The full chapter on Server Actions builds this body out with the error-funnel detail and the useActionState integration; here, the signature is the contract.
requireRole, the authorization wrapper
Section titled “requireRole, the authorization wrapper”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 role union, the value-derived Role type from the last lesson, and declares an unconstrained Output. ActionCtx<R> is itself a generic type, and its role field narrows to whatever literal R the caller supplied. The point is to carry a literal through a wrapper, so downstream code can read it precisely instead of as the broad Role union. This is why the value-derived Role and the <const T> modifier from the last section are paired tools.
export const exportBilling = requireRole('owner', async (ctx) => { // ctx.role is 'owner', not Role return generateBillingExport(ctx.orgId);});The chapter on RBAC builds this wrapper end-to-end with the audit-trail integration and the full ActionCtx<Role> shape.
memoize, preserve a function’s call shape
Section titled “memoize, preserve a function’s call shape”The third pattern is the one every cache, retry, instrumentation, and decorator wrapper uses. The wrapper takes any function and returns a function with the same call signature: the same arguments, the same return type, and the same TypeScript hints at the call site.
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 generic idiom. P extends unknown[] means “any tuple of value-parameter types,” so the constraint accepts any function’s parameter shape, captured as a tuple. (...args: P) then consumes that tuple as a rest parameter, and the returned function spreads the same tuple back out. The wrapped function ends up with the exact same call shape as the input, and the caller’s IDE shows the same parameter hints it would on the original. The pattern shows up in every logger decorator, every profiler hook, and every third-party type definition that wraps a function without losing its signature.
One note on scope. The course’s production caching tool is Next.js 16’s 'use cache' directive, which the chapters on caching and rate limiting cover. This memoize is a teaching vehicle for the <P extends unknown[], R> pattern, not the cache you’ll ship. Don’t ship this body. JSON.stringify(args) as a cache key is fragile: it throws on circular refs, silently drops undefined, and doesn’t guarantee key ordering across object shapes. Production-grade keying is a topic for those later chapters. What you need from this section is to recognize the pattern when you see it in the wild.
Conventional names and the variance footnote
Section titled “Conventional names and the variance footnote”Two short pieces, both for recognition rather than daily use.
Conventional names. T for “any type,” K for “key,” V for “value,” R for “return,” P for “parameters,” and E for “element” or “error.” These name the position, not the domain. Reach for a longer name like TInvoice or TError only when a single signature juggles three or more generics and position alone is hard to read, which is rare in app code. The single-letter convention isn’t laziness; every TypeScript reader recognizes these names, so the signature reads faster.
Variance, named once. Type parameters can behave differently under subtyping, which is called variance . A parameter that only appears in output positions is covariant, meaning a narrower T flows through. A parameter that only appears in input positions is contravariant, meaning a wider T flows through. A parameter that appears in both is invariant. TypeScript 4.7 added explicit in and out annotations, like Producer<out T> and Consumer<in T>, so library authors can declare variance directly. This is library-author tooling. You’ll see these annotations in framework types from React, Effect, and fp-ts, but you won’t add them in app code. They’re named here only so the syntax doesn’t surprise you the first time you encounter it.
What this lesson doesn’t reach for
Section titled “What this lesson doesn’t reach for”Knowing what this lesson leaves out is the other half of knowing what it covers. Everything below exists, and you’ll read it eventually, but you won’t author it in 2026 SaaS app code.
-
Conditional types (
T extends U ? X : Y) andinfer(T extends Promise<infer R> ? R : never). These are library-author primitives, the building blocks ofAwaited<T>, Zod’s inferred types, and most framework helpers. You read them in framework types; you don’t write them in app code. The TypeScript Handbook chapter on conditional types is the right resource if you’re curious. -
Mapped types as generic transforms (
type Lazy<T> = { [K in keyof T]: () => T[K] }). The last lesson named them once, and the same applies here. You’ll read them in library definitions, not author them in feature code. -
NoInfer<T>, a TypeScript 5.4 utility that prevents inference from a specific position. The narrow trigger is a generic function with a default-providing parameter where inference from the default would widenTincorrectly. The fix is wrapping that position inNoInfer<T>. It’s worth knowing the name exists so you can grep for it if you hit the symptom, but it isn’t exercised here. -
Higher-kinded types and type-level programming. This is Effect and fp-ts territory, and the course doesn’t reach it.
-
Generic classes. Classes are a narrow reach in this course; the chapter on async and errors covers them for custom error classes specifically. The generic syntax applies to classes the same way it applies to functions, using the same
<T extends ...>rules, so it needs no separate treatment. -
Function overloads, the legacy “two call shapes, one body” form. You’ll see them in some library declarations, with
Array.prototype.flat’s definition as the canonical example. The 2026 default is generics with constraints; overloads are named here only so you don’t trip over them when reading.d.tsfiles.
That boundary is what keeps the lesson focused on what a SaaS engineer ships: function and type generics, the extends constraint in its three shapes, defaults, <const T>, the K extends keyof T signature, and three production wrapper signatures. The library-author layer above this can wait until you genuinely need it.
Exercise: write pluck
Section titled “Exercise: write pluck”This is the central exercise of the lesson. You’ll write the signature you just walked through, and the type-checker will confirm that 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
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, and 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 the caller passed ('id', 'name', 'age'), so the return type recomputes per call as UserId, string, and number. The invalid 'email' key fails the keyof T constraint, which is what makes the @ts-expect-error directive valid.
If you can write that signature from memory, you’ve absorbed the lesson. Every other wrapper in this course leans on the same K extends keyof T pattern in one form or another.
Exercise: match the wrapper shape
Section titled “Exercise: match the wrapper shape”This is a quick confirmation drill: five scenarios and five generic signatures. Pair each scenario to the shape that fits it.
Match each generic-function scenario on the left to the generic-parameter shape that fits it. Click an item on the left, then its match on the right. Press Check when done.
<T>(value: T): T<T, K extends keyof T>(obj: T, key: K): T[K]<P extends unknown[], R>(fn: (...args: P) => R): (...args: P) => R'home' | 'about', not string)<const T extends readonly string[]>(values: T): TResult<T> whose error defaults to AppError but the caller can overridetype Result<T, E = AppError> = ...External resources
Section titled “External resources”Four places to go deeper if you want more on the patterns this lesson surfaces.
The canonical reference. This handbook chapter covers the same shapes this lesson surfaced, plus the library-author depth this lesson deliberately skipped.
Matt Pocock's exercise-driven workshop on the same generics patterns. Worth the time if you want to write them daily, not just read them.
Official release notes for the `<const T>` modifier this lesson covered. Read it once so you recognize the feature's scope and limits.
Official release notes for the utility this lesson named but didn't exercise. The trigger and the fix are both in the writeup.
You can now write a constrained generic function from memory, particularly pluck<T, K extends keyof T>, read and defend the three production wrapper signatures in a code review, reach for <const T> when literals must survive the call site, and recognize variance annotations in library types without trying to use them in app code. The chapter ends here; the next one takes the same TypeScript floor and builds modules on top of it.