TypeScript Generics and Utility Types: A Deep Dive
Generics are among TypeScript's most powerful and most overused features. This guide explains constraints, defaults, inference, built-in Utility Types, when generics help, and when they do not, with one principle throughout: types should help you, not trap you.
Key Points
- Topic Focus: Next.js、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
TypeScript Generics and Utility Types: A Deep Dive
Generics are TypeScript's most polarizing feature. Developers who use them well cannot live without them: one function safely adapts to ten types, type inference remains automatic throughout the call chain, and a change in one place propagates everywhere. Developers who use them poorly hate them: signatures accumulate three or four parameters such as <T, U, K extends keyof T>, compiler errors read like an incomprehensible tome, and the next colleague to maintain the code reaches straight for any.
The difference between these outcomes does not lie in generics themselves. It lies in whether the developer using them has clearly identified what is being parameterized.
This article is not about type gymnastics. It covers three things: how to use generics correctly through constraints, defaults, and inference; how to use the built-in Utility Types as everyday tools; and, most importantly, when not to use generics. The type system is a tool, not the goal. By the end, you should be able to tell which generics in a codebase provide value and which are liabilities.
1. The Essence of Generics: Treating Types as Parameters
Let us establish the concept first. A generic is a parameterized type: you postpone the decision about the concrete type until the point of use and represent some type with a placeholder such as T, U, or K.
The classic example is an identity function: whatever goes in must come back out with the same type.
// Without generics: type information is lost
function identity(value: any): any {
return value;
}
const n = identity(42);
// ^? any — TS does not know it is a number, so all type protection is lost
// With generics: the type is remembered
function identity<T>(value: T): T {
return value;
}
const n = identity(42);
// ^? number
const s = identity('hello');
// ^? string
<T> declares a type variable named T. When identity(42) is called, TS infers that T = number, so both the argument and return value become number. One function adapts to every type, while the type at each call site remains precise. That is the value of generics: write once, support multiple types, and preserve type safety.
This mental model is exactly like function parameters. A function parameter parameterizes a value, with the concrete value supplied at call time. A generic parameterizes a type, with the concrete type supplied at call time. Once you understand this layer, generics stop being mysterious.
Generics can be used with functions, interfaces, type aliases, and classes:
// Interface
interface Box<T> {
value: T;
}
const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: 'hi' };
// Type alias
type Pair<A, B> = { first: A; second: B };
const p: Pair<number, string> = { first: 1, second: 'a' };
// Class
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const nums = new Stack<number>();
nums.push(1); // OK
// nums.push('x'); // Error
The key insight is that generics do not make code more advanced; they let one piece of logic be reused across different types while keeping those types precise. If a generic does not provide both reuse and type precision, it is probably unnecessary.
2. Generic Inference: Most of the Time, You Do Not Need to Write Type Arguments
Beginners often make one mistake when writing generic code: they explicitly specify type arguments at every call site.
const n = identity<number>(42); // Redundant
const arr = Array.from<string>(['a', 'b']); // Redundant
This defeats the purpose of generics. One of their core benefits is automatic inference at the call site, eliminating the need to write type annotations. TS works backward from the supplied arguments to infer T. In identity(42), TS sees that 42 is a number and automatically infers T = number; there is no need to write <number>.
When do you need to specify type arguments explicitly? In three situations:
// 1. The inferred result is not precise enough and needs to be narrowed
function parseJSON<T>(raw: string): T {
return JSON.parse(raw) as T;
}
// Without <User>, T is inferred as unknown/default, so field suggestions are unavailable
const user = parseJSON<User>('{"id":1}');
user.id; // Suggestions are available
// 2. The inferred type is too broad, such as when a literal is inferred as its base type
function tuple<T extends readonly unknown[]>(...args: T): T {
return args;
}
// Without an explicit type, t1 will be inferred as (string | number)[]
const t1 = tuple<string, number>('a', 1);
// 3. Inference is impossible because the input type and target type are not directly related
function create<T>(): T { /* ... */ return {} as T; }
const x = create<Foo>(); // Must be explicit because T cannot be inferred
Principle: if it can be inferred, do not write it. Explicit type arguments are a way to override inference and should be used only when inference does not produce the intended result. Code filled with <T, U, K> usually indicates that the author has not determined which arguments can be omitted.
3. Generic Constraints (extends): Drawing a Boundary Around a Type Parameter
An unconstrained T means any type, which is often too broad. If you want to access a property of T inside a function, TS stops you because it does not know whether T has that property.
// Error: Property 'length' does not exist on type 'T'
function getLength<T>(value: T): number {
return value.length;
}
Use extends to add a constraint to T. T extends U means that T must be a subtype of U, satisfying the shape of U.
// T must have a length property
function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK
}
getLength('hello'); // OK, string has length
getLength([1, 2, 3]); // OK, arrays have length
// getLength(42); // Error, number does not have length
The most common constraints are T extends keyof U, where T must be one of the keys of U, and T extends string | number, which restricts a parameter to primitive types. Consider a practical example: a type-safe property accessor.
function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
return keys.map((k) => obj[k]);
}
const user = { id: 1, name: 'Alice', age: 30 };
pluck(user, ['name', 'age']); // OK, returns (string | number)[]
// pluck(user, ['email']); // Error: 'email' is not a key of user
The value of K extends keyof T is that an incorrect key name is rejected at compile time instead of producing undefined at runtime. This is the typical benefit of combining generics with constraints: turning a requirement that would otherwise exist only in documentation into a compile-time check.
One frequently overlooked point is that a constraint is not an assertion. T extends { length: number } guarantees only that T has at least a length property, not that length is its only property. It is therefore safe to use T as { length: number } inside the function, while a returned T still retains its precise type. That is the subtle strength of constraints: they narrow what can be done while preserving precision about what the type actually is.
4. Generic Defaults: Making Type Parameters Optional
A type parameter can have a default value, using the syntax T = SomeType. The default provides a fallback type when no type argument is supplied and is common in utility types and library API design.
interface PaginatedResponse<T = unknown> {
data: T[];
total: number;
page: number;
}
// No T supplied — data is unknown[]
const res1: PaginatedResponse = { data: [], total: 0, page: 1 };
// T supplied — data is User[]
const res2: PaginatedResponse<User> = { data: [user1, user2], total: 2, page: 1 };
Defaults are especially useful when most call sites use one fixed type and only a few require customization. For example, fetchJSON<T = unknown>(url) returns unknown by default, forcing the caller to narrow the result instead of trusting it blindly, while still allowing a concrete type to be supplied when needed.
Defaults can also be combined with constraints:
function createFetcher<T extends string = 'GET'>(method?: T) {
// ...
}
My practical recommendation is to consider defaults first for utility types and library functions, while generic functions in business code usually do not need them. The types of a business function's inputs should be determined by the call site; a default may instead conceal that the caller did not specify the type clearly.
5. Built-in Utility Types Quick Reference: 80% of Everyday Needs
TS includes a collection of utility types. They are essentially type-level functions built with conditional types, mapped types, and infer. You do not need to understand their implementations, which is a topic for another article. You only need to know which one to use in each situation. This section is a quick-reference guide worth bookmarking.
First, define an example type that we will use throughout the article:
interface User {
id: number;
name: string;
email?: string; // Optional
readonly createdAt: Date;
}
Partial — Make Every Property Optional
type UpdateUser = Partial<User>;
// Equivalent to { id?: number; name?: string; email?: string; createdAt?: Date }
Use cases: PATCH request bodies, partial field updates in forms, and incremental object construction.
function updateUser(id: number, patch: Partial<User>) {
// Every field in patch may be absent, but any field that is present has the correct type
}
updateUser(1, { name: 'Bob' }); // OK, only update name
Required — Make Every Property Required (the Inverse of Partial)
type StrictUser = Required<User>;
// Equivalent to { id: number; name: string; email: string; createdAt: Date }
Use cases: Some workflows require every field to be present, such as validation before a database write. Use Required to make optional properties mandatory. Does it remove readonly as well? No. Required removes only ? and does not affect readonly.
Pick — Select Specific Properties
type UserSummary = Pick<User, 'id' | 'name'>;
// Equivalent to { id: number; name: string }
Use cases: Extract a publicly exposed subset from a large object. An API list can return a summary, a JWT payload may need only id and name, and component props may need only part of a parent type.
Omit — Exclude Specific Properties (the Inverse of Pick)
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// Equivalent to { name: string; email?: string }
Use cases: This is the utility type used most often when implementing backend CRUD operations. When creating an object, the database generates id and the server fills in createdAt, so the frontend should not send either field. Omit removes them, leaving exactly the input the user should submit.
Record — Construct a Key-to-Value Mapping
type UserRole = 'admin' | 'editor' | 'viewer';
type RoleCount = Record<UserRole, number>;
// Equivalent to { admin: number; editor: number; viewer: number }
const counts: RoleCount = { admin: 3, editor: 5, viewer: 12 }; // All three keys are required
Use cases: Enum-to-value mappings, state machines, and configuration tables. Record<K, V> is much stricter than { [key: string]: V }: the former requires keys from the set K, while the latter accepts any string and makes missing keys easy to overlook.
// State-machine state → handler function
const handlers: Record<Status, (s: State) => State> = {
idle: handleIdle,
loading: handleLoading,
done: handleDone,
};
// Omitting any state produces a compile-time error
Readonly — Make Every Property Read-Only
type FrozenUser = Readonly<User>;
// Every property becomes readonly, and assignment produces an error
Use cases: Declare a function parameter as Readonly<T> to promise that the function will not modify it; enforce immutable state in Redux and other state-management systems. readonly exists only at compile time and does not freeze the object at runtime. Do not confuse it with Object.freeze.
ReturnType — Obtain a Function's Return Type
function getUser(id: string): Promise<User> { /* ... */ }
type R = ReturnType<typeof getUser>;
// ^? Promise<User>
Use cases: Avoid writing the same type twice. If the function's return value changes, every reference updates automatically.
Parameters — Obtain a Function's Parameter Types as a Tuple
type P = Parameters<typeof getUser>;
// ^? [id: string]
type FirstParam = P[0]; // string
Use cases: Reuse a function's parameter types when wrapping it.
Awaited — Unwrap a Promise Recursively
type R1 = Awaited<Promise<User>>; // User
type R2 = Awaited<Promise<Promise<number>>>; // number (recursively unwrapped)
type R3 = Awaited<User>; // User (non-Promise types are returned unchanged)
Use cases: Awaited<ReturnType<T>> is the ideal combination for inferring async function types, as the practical example below will demonstrate.
How These Utility Types Relate
Remember these pairs to choose quickly:
| Utility Type | Effect | Counterpart |
|---|---|---|
Partial<T> | Select all → make all optional | Required<T> |
Pick<T, K> | Keep specified properties | Omit<T, K> |
Readonly<T> | Add readonly | (No built-in inverse; define your own) |
ReturnType<T> | Obtain return values | Parameters<T> (obtain parameters) |
Awaited<T> | Unwrap a Promise | (Use with ReturnType) |
Master these and they will cover 80% of everyday requirements for reshaping object types, with no need to write complex types yourself.
6. Practical Example 1: Inferring API Response Types with Required Picked Fields and Optional Partial Fields
Consider a real scenario. You have a complete User entity, but an API list response needs only some fields, with name required and avatar optional. How can you compose this type with Utility Types instead of writing another copy manually?
interface User {
id: number;
name: string;
email?: string;
avatar?: string;
createdAt: Date;
deletedAt: Date | null;
}
// List response: only id, name, and avatar, with avatar optional
type UserListItem = Pick<User, 'id' | 'name' | 'avatar'>;
// { id: number; name: string; avatar?: string }
Pick preserves each original property's optionality. Because avatar was already marked with ?, it remains optional when selected. This is desirable: one source of truth, with no duplicate definitions.
Sometimes, however, you need to control optionality explicitly. For example, an edit-user form may require every field, including the originally optional email, while prohibiting changes to id:
// Edit form: require every field, but exclude id and timestamps
type EditUserForm = Omit<Required<User>, 'id' | 'createdAt' | 'deletedAt'>;
// { name: string; email: string; avatar: string }
Required<User> first makes every property required, and Omit then removes the fields that should not appear. This two-step composition is much safer than rewriting the interface manually. When User gains a property, the form type will not silently become outdated.
Now consider a paginated response wrapper, one of the most common patterns in Next.js and full-stack projects:
interface Paginated<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
// Backend function
async function fetchUsers(page: number): Promise<Paginated<UserListItem>> {
const res = await fetch(`/api/users?page=${page}`);
return res.json();
}
// The frontend derives the type directly from the function, with no manual definition
type UsersResponse = Awaited<ReturnType<typeof fetchUsers>>;
// Paginated<UserListItem>
type SingleUser = UsersResponse['data'][number];
// UserListItem — the type of one element in the list
The essence of this code is that the return type of fetchUsers is the single source of truth, while the frontend's UsersResponse and SingleUser are derived entirely from it. When the backend changes a field, the frontend types update automatically, eliminating bugs caused by mismatches between an API and its types. This single-source derivation pattern is one of the main benefits of full-stack TypeScript development.
7. Practical Example 2: Composing Component Props with the Power of Intersection Types &
The worst approach to Props for React components, including Next.js Server Components, is to place every field in one large interface. Good Props design composes small types, primarily through the intersection type & and Utility Types.
// Base button properties, reusing the native button type
type BaseButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
// Additional properties for the loading state
type LoadingProps = {
loading?: boolean;
};
// Additional icon properties
type IconProps = {
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
};
// Compose the final Props type
type ButtonProps = BaseButtonProps & LoadingProps & IconProps;
function Button({ loading, icon, iconPosition, ...rest }: ButtonProps) {
// rest automatically receives every native button property: onClick, disabled, type, and so on
return <button {...rest}>{/* ... */}</button>;
}
& merges multiple types into one: the final object must satisfy all of them simultaneously. This has three advantages over placing every property in one interface: reusability (LoadingProps can be used by other components), readability (the composition of Props is immediately visible), and evolvability (adding a capability means intersecting one more type).
Here is another common Next.js example: page Props composed from route parameters and data.
import { notFound } from 'next/navigation';
// Route parameters: dynamic route [slug]
type Params = { slug: string };
// Search parameters: ?ref=xxx
type SearchParams = { [key: string]: string | string[] | undefined };
// Page Props = route parameters + search parameters + data
type PageProps = {
params: Promise<Params>;
searchParams: Promise<SearchParams>;
};
export default async function PostPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
// ...
}
This code does not use generics because the page Props have a fixed shape. A fixed shape should use a concrete type instead of forcing in a generic merely to appear reusable.
When should Props use a generic? When the type of one component property depends on another property. The classic example is a generic select component:
interface SelectProps<T extends string> {
options: readonly T[];
value: T;
onChange: (value: T) => void;
placeholder?: string;
}
function Select<T extends string>(props: SelectProps<T>) {
// ...
}
const status = Select<'idle' | 'done' | 'error'>({
options: ['idle', 'done', 'error'],
value: 'idle',
onChange: (v) => console.log(v), // The type of v is inferred precisely
});
T extends string strongly links the T used by options, value, and onChange. If you pass options of type ('a' | 'b')[], onChange can receive only 'a' | 'b', preventing invalid values. This is a high-value use of generics in components: multiple properties have a type dependency.
The criterion is simple: use a generic when properties must change together; otherwise, composing types with intersections is enough.
8. When You Should Not Use Generics
After discussing so many benefits, we must examine the other side: most excessively complex generics are misuse. This is the point I most want to emphasize because beginner and intermediate developers are particularly likely to fall into the trap of using generics whenever they see an opportunity.
Signal One: If a Union Solves It, Do Not Use a Generic
// ❌ Overengineered: T appears only twice and imposes no meaningful constraint
function format<T extends string | number>(value: T): string {
return String(value);
}
// ✅ A union is enough: simple and direct
function format(value: string | number): string {
return String(value);
}
Rule of thumb: if a type parameter appears only in a simple relationship such as keeping an input and output the same type, with no dependency across properties, a union is clearer. The cost of a generic, including cognitive load and more complex error messages, is worthwhile only when its benefits are substantial enough.
Signal Two: The Generic Parameter Is Not Truly Used
// ❌ T is declared, but the function body never uses its type information
function log<T>(message: string): void {
console.log(message);
}
// ✅ Remove it
function log(message: string): void {
console.log(message);
}
A common variation adds <T> to every function to make it look reusable, even though T has no constraint and creates no meaningful relationship in the signature. Such a generic is decorative; removing it sacrifices no type safety.
Signal Three: There Are More Than Three Generic Parameters
// ❌ Nobody wants to read a signature like this
function transform<T, U, K extends keyof T, V, W>(...): ??? { ... }
As the number of type parameters grows, readability deteriorates, inference becomes more error-prone, and compiler errors become harder to understand. As a rule of thumb, more than three generic parameters usually means you are writing a program in the type system rather than expressing a constraint. At that point, splitting the logic into multiple functions or stepping back to redesign the API is generally better than forcing the generic design further.
Signal Four: Every Call Site Uses Explicit Type Arguments
// Every call site needs <User>, <Post>, and so on, which means inference is not helping
const a = fetch<User>('/users/1');
const b = fetch<Post>('/posts/1');
If every call site requires manually written type arguments, the generic design often has a problem: either its constraints are inadequate or the API has the wrong shape. A good generic API should produce a clean call site such as fetchResource('/users/1'), with the type inferred automatically.
Signal Five: Using Generics for Their Own Sake and Complicating a Simple Problem
// ❌ A function that handles only User is forced to be generic
function getUserName<T extends User>(user: T): string {
return user.name;
}
// ✅ This function serves only User, so say so directly
function getUserName(user: User): string {
return user.name;
}
Possible future reuse is not a reason to introduce a generic today. The YAGNI (You Aren't Gonna Need It) principle applies equally to type design. If a second type genuinely needs the function later, converting it to a generic then will cost far less than carrying an overabstracted signature now.
9. Types Should Help You, Not Trap You
The previous sections covered technical judgment; this one covers philosophy. I have seen too many teams use TypeScript backward. Their original intention was to prevent bugs with types, but they ended up with a type system that forced them to write as any, @ts-ignore, and as unknown as X merely to make the code run. In that situation, the types are not helping; they are trapping you.
Here are several warning signs:
Signal One: as assertions appear constantly in business code. A type assertion tells the compiler to stop objecting because you know what you are doing. Occasional use is reasonable, such as when integrating with an external library or parsing JSON. But if every ten lines contain an as, the type design does not match the actual business logic: either the types are too strict or they are wrong. The correct response is to fix the types, not accumulate as assertions.
Signal Two: A utility type exceeds five lines, or you cannot understand it yourself a month later. This is the red line for type gymnastics. Type gymnastics, such as implementing algorithms at the type level with recursive conditional types, are excellent learning exercises, but they should not enter production code. The reasons are practical: error messages become hundreds of characters of incomprehensible text, changing one property can collapse inference, and compilation becomes visibly slower. I have one hard rule: after writing a utility type, keep it if you can explain what it does to a colleague in one sentence; if you cannot, split it up or delete it.
Signal Three: any is quietly hidden away to satisfy the type checker. as any, // @ts-ignore, and converting an object to unknown before converting it again to the target type are all forms of type debt. They save time in the short term but postpone bugs until runtime and leave no useful clues during debugging. unknown is better than any because unknown forces you to narrow it; any means giving up completely. If a section of code genuinely cannot avoid any, at least add a comment explaining why and when the debt can be repaid.
Signal Four: Type inference is slow enough to disrupt development. tsserver stalls, IDE completion is delayed, or tsc takes tens of seconds. These problems are often caused by excessively complex types, especially deeply recursive conditional types. The type system is not free; complex inference has a computational cost. Performance, like readability, is a constraint on type design.
What do good types look like? I use three criteria:
- They prevent real bugs: incorrect field names, arguments in the wrong order, and unhandled null values are rejected at compile time.
- They improve DX (developer experience): while writing code, the IDE suggests available fields and valid options without requiring you to consult documentation.
- Their maintenance cost is lower than the cost of the bugs they prevent: if maintaining a type is harder than writing tests manually, that type is a liability.
Utility Types are effective because they occupy this sweet spot: with minimal syntax such as Partial<T>, Pick<T, K>, and Awaited<ReturnType<T>>, they deliver substantial type-safety and completion benefits while remaining immediately understandable to any TypeScript developer. That is how a type system should work: lightweight, precise, and helpful rather than restrictive.
Summary
- Generics are parameterized types: they postpone the choice of a concrete type until the call site and use
Tas a placeholder. Their core benefit is writing code once, adapting it to multiple types, and preserving precise types at every call site. If a type can be inferred, do not write the type argument manually. - Constraints (
extends) draw a boundary around type parameters:T extends keyof Uturns a documentation-level requirement into a compile-time check without losing the precision ofT. Defaults such asT = stringmake type parameters optional and are well suited to utility types and library APIs. - Built-in Utility Types are the everyday workhorses:
Partial/Required,Pick/Omit,Record,Readonly,ReturnType/Parameters, andAwaited. Remember the corresponding pairs, and you can handle 80% of object-shape transformations without writing custom types. - Two practical patterns are especially valuable: derive API response types from a single source with
Awaited<ReturnType<T>>plusPick/Omit, so frontend types update automatically when backend fields change; compose component Props from small types with the intersection operator&, and introduce generics only when properties have interdependent types. - Overusing generics creates debt: if a union solves the problem, do not use a generic; more than three generic parameters usually signals a design problem; explicit type arguments at every call site mean inference is not helping; and type gymnastics do not belong in production code.
Key takeaway: a good type system does not force you to write more <T>. It uses the least possible type syntax to enforce the most constraints at compile time. Types are a tool, not the goal. When type code makes you feel clever but leaves your colleagues confused, it has already begun to trap you.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.