TypeScriptâs type system goes far beyond annotating variables with string or number. The real power lives in utility types, built-in generics that transform existing types into new ones without duplicating definitions. Most codebases use two or three of them and ignore the rest. Thatâs a missed opportunity, because the right utility type can replace dozens of lines of manual type definitions and catch bugs that unit tests never would.
Partial and Required: toggling optionality
Partial<T> makes every property in a type optional. Itâs the natural fit for update functions where you only send the fields that changed.
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
async function updateUser(id: string, fields: Partial<User>) {
return db.user.update({ where: { id }, data: fields });
}
// Only send what changed
await updateUser('123', { name: 'Thomas' });
Required<T> does the opposite. It forces every optional property to be defined, which is useful when a config object has defaults but must be fully resolved before use.
interface AppConfig {
apiUrl?: string;
timeout?: number;
retries?: number;
}
function resolveConfig(partial: AppConfig): Required<AppConfig> {
return {
apiUrl: partial.apiUrl ?? 'https://api.example.com',
timeout: partial.timeout ?? 5000,
retries: partial.retries ?? 3,
};
}
The return type guarantees that every consumer of the resolved config gets defined values. No optional chaining needed downstream.
Pick and Omit: shaping types from existing ones
Pick<T, K> extracts a subset of properties. Instead of defining a new interface that duplicates half of an existing one, you derive it.
type UserCardProps = Pick<User, 'name' | 'email' | 'role'>;
function UserCard({ name, email, role }: UserCardProps) {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
<span>{role}</span>
</div>
);
}
If User gains a new field, UserCardProps stays untouched. If name changes type from string to something else, UserCardProps updates automatically. The type stays in sync with its source without any manual effort.
Omit<T, K> works the other way, keeping everything except the specified keys. Itâs ideal for props that forward most of an interface but handle some properties internally.
type CreateUserInput = Omit<User, 'id'>;
function CreateUserForm({ onSubmit }: { onSubmit: (data: CreateUserInput) => void }) {
// id is generated server-side, the form doesn't need it
}
Record: typed dictionaries
Record<K, V> creates an object type where every key of type K maps to a value of type V. It replaces the loose { [key: string]: any } pattern with something that TypeScript can actually enforce.
type Role = 'admin' | 'editor' | 'viewer';
interface Permissions {
canEdit: boolean;
canDelete: boolean;
canPublish: boolean;
}
const rolePermissions: Record<Role, Permissions> = {
admin: { canEdit: true, canDelete: true, canPublish: true },
editor: { canEdit: true, canDelete: false, canPublish: true },
viewer: { canEdit: false, canDelete: false, canPublish: false },
};
If you add a new role to the Role union, TypeScript immediately flags the Record as incomplete. Every role must be accounted for. This turns a runtime âoops, we forgot the new roleâ into a compile-time error.
Extract and Exclude: filtering union types
Extract and Exclude operate on union types. Extract<T, U> keeps only the members of T that are assignable to U. Exclude<T, U> removes them.
type AppEvent =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string }
| { type: 'scroll'; offset: number }
| { type: 'resize'; width: number; height: number };
// Only mouse-related events
type MouseEvent = Extract<AppEvent, { type: 'click' }>;
// Everything except scroll
type NonScrollEvent = Exclude<AppEvent, { type: 'scroll' }>;
This is particularly powerful in event systems and state machines where you need to narrow a broad union to a specific subset without rewriting the types.
ReturnType and Parameters: deriving from functions
When a functionâs return type is complex or comes from a third-party library, ReturnType extracts it without requiring a separate type definition.
function useAuth() {
return {
user: getCurrentUser(),
isAdmin: checkAdminRole(),
logout: () => clearSession(),
};
}
// Derived automatically, stays in sync if useAuth changes
type AuthContext = ReturnType<typeof useAuth>;
Parameters does the same for function arguments. Itâs useful when wrapping functions or building middleware that needs to match a functionâs signature.
function createUser(name: string, email: string, role: Role) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string, role: Role]
Custom generics: building your own utility types
The built-in utilities are themselves just generic types. Building custom ones follows the same pattern and lets you encode your domain constraints into the type system.
type ApiResponse<T> = { status: 'success'; data: T } | { status: 'error'; error: string };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const user = await db.user.findUnique({ where: { id } });
return { status: 'success', data: user };
} catch (e) {
return { status: 'error', error: 'User not found' };
}
}
// Make specific keys optional while keeping the rest required
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type CreateUserInput = PartialBy<User, 'id' | 'role'>;
// id and role are optional, name and email are required
Custom generics compose with the built-in ones. PartialBy uses Omit, Partial, and Pick together to express a constraint that none of them can express alone. Once defined, itâs reusable across the entire codebase.
Conditional types: types that branch
Conditional types let you express âif A, then B, else Câ at the type level. They power most of the advanced patterns in libraries like Zod, tRPC, and TanStack Router.
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
A more practical example: extracting the resolved type from a promise, regardless of nesting depth.
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
type Result = UnwrapPromise<Promise<Promise<string>>>;
// string
The infer keyword captures the inner type during pattern matching. Combined with recursion, it can peel through any number of wrapper types to get to the value inside.
Conclusion
TypeScriptâs utility types arenât academic abstractions. Theyâre practical tools that keep type definitions DRY, catch refactoring mistakes at compile time, and make impossible states truly impossible. The best TypeScript code doesnât have more types. It has fewer, smarter types that derive from a single source of truth and propagate changes automatically.