Utility types you'll use weekly
After five years shipping production platforms, I've learned that TypeScript's built-in utility types are the difference between code that scales and code that becomes a maintenance nightmare. They're not flashy features you show off in demos—they're the backbone of maintainable APIs, flexible components, and type-safe refactors. In this post, we'll walk through the utility types you'll reach for constantly and see how they solve real problems in e-commerce, SaaS, and API layers.
The most useful utilities: TYPE vs DOES
TypeScript ships with about 15 built-in utility types. Most projects use maybe five of them regularly. Let me break down the ones that matter and what each one actually solves.
Partial<T>: All properties optional
When you need to update a resource—a user profile, order status, or CRM contact—you rarely want to require every field. Partial<T> makes every property optional.
interface User {
id: string;
name: string;
email: string;
phone: string;
createdAt: Date;
}
// Without Partial: you'd have to write this
interface UserUpdateRequest {
name?: string;
email?: string;
phone?: string;
// But not id or createdAt—manual and error-prone
}
// With Partial: one line
type UserUpdateRequest = Partial<User>;
// Usage
function updateUser(id: string, updates: Partial<User>) {
// Only the provided fields will be updated
db.users.update(id, updates);
}I use this in every REST API handler that accepts PATCH requests. It saves lines of code and keeps the source of truth in one place—the domain model.
Pick<T, K> and Omit<T, K>: Select or remove keys
Pick lets you select specific properties; Omit removes them. These are essential when you need different views of the same entity.
interface User {
id: string;
email: string;
password: string; // Never expose
name: string;
phone: string;
internalNotes: string; // Admin only
}
// Public response to clients
type UserPublicProfile = Omit<User, 'password' | 'internalNotes'>;
// Or explicitly select what's allowed
type UserPublicProfile = Pick<User, 'id' | 'email' | 'name'>;
// API response
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
const safe: UserPublicProfile = {
id: user.id,
email: user.email,
name: user.name,
};
res.json(safe);
});In a ride-hailing app, I used Pick to build driver profiles that show location and rating but hide contact details. In a trading platform, Omit kept sensitive portfolio data off the public API surface.
Record<K, V>: Typed dictionaries
Record creates an object type where every key must come from a union and every value has the same type. It's perfect for configuration, feature flags, or lookup tables.
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
// Without Record: you'd lose type safety
const statusMessages = {
pending: 'We received your order.',
processing: 'We are packing it now.',
shipped: 'Your order is on its way.',
// TypeScript won't complain if you forget 'delivered' or 'cancelled'
};
// With Record: completeness is enforced
const statusMessages: Record<OrderStatus, string> = {
pending: 'We received your order.',
processing: 'We are packing it now.',
shipped: 'Your order is on its way.',
delivered: 'Your order has arrived.',
cancelled: 'Your order was cancelled.',
// TypeScript error if any key is missing
};
function getStatusMessage(status: OrderStatus): string {
return statusMessages[status];
}I've built feature flags as Record<FeatureName, boolean>, payment method handlers as Record<PaymentProvider, PaymentHandler>, and environment-specific configs this way. It catches missing cases at compile time.
ReturnType<F>: Type of a function's result
When you have a complex function, extracting its return type keeps your types synchronized without duplication.
async function fetchUserWithOrders(id: string) {
const user = await db.users.findById(id);
const orders = await db.orders.findByUserId(id);
return { user, orders, fetchedAt: new Date() };
}
// Manually writing this is fragile:
// interface FetchResult {
// user: User;
// orders: Order[];
// fetchedAt: Date;
// }
// Better: let TypeScript derive it
type FetchResult = ReturnType<typeof fetchUserWithOrders>;
// If fetchUserWithOrders changes, FetchResult updates automatically
const result: FetchResult = await fetchUserWithOrders('123');This shines when your return type is complex or comes from a library. I used it in a CRM to track what our API integration actually returns—when the upstream API changed, TypeScript told us immediately instead of finding out in production.
Awaited<T>: Unwrap promises
Awaited extracts the type inside a Promise. It's invaluable when working with async operations.
async function getUser(id: string): Promise<User> {
return db.users.findById(id);
}
// The promise type
type UserPromise = ReturnType<typeof getUser>; // Promise<User>
// The actual data type
type UserData = Awaited<ReturnType<typeof getUser>>; // User
// Or more simply
type UserData = Awaited<typeof getUser(...)>; // User
// Real use: a service that caches user data
class UserCache {
private cache = new Map<string, Awaited<ReturnType<typeof getUser>>>();
async getOrFetch(id: string) {
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
const user = await getUser(id);
this.cache.set(id, user);
return user;
}
}In a Next.js project handling server-side props that return promises, Awaited let us type-check the resolved value without manually writing it twice.
Derive, don't duplicate
The core principle behind utility types is single source of truth. If you define a User type in your domain, you shouldn't manually write a UserDTO, UserResponse, and UserUpdateRequest. Derive them.
// Domain type: source of truth
interface User {
id: string;
email: string;
password: string;
name: string;
phone: string;
role: 'admin' | 'user';
createdAt: Date;
updatedAt: Date;
}
// Derived types: zero manual maintenance
type UserDTO = Omit<User, 'password'>; // Safe to send to clients
type UserUpdateDTO = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt' | 'password'>>; // Update payload
type UserPublicProfile = Pick<User, 'id' | 'name' | 'email'>; // Minimal public data
// In your API
app.patch('/users/:id', async (req, res) => {
const updates: UserUpdateDTO = req.body;
const user = await db.users.update(req.params.id, updates);
const response: UserDTO = user;
res.json(response);
});When you add a new field to User—say, `lastLoginAt`—all your derived types automatically include it or exclude it based on the rules you've already set. This is how you avoid cascading type bugs across a codebase.
Combine them: Real-world patterns
The real power emerges when you layer utility types. Here are patterns I use weekly.
Update payloads: Partial + Pick
Most updates don't touch every field. You want a subset of fields, all optional.
interface Order {
id: string;
userId: string;
items: OrderItem[];
status: 'pending' | 'processing' | 'shipped';
total: number;
createdAt: Date;
updatedAt: Date;
}
// Only allow updating status and notes; both optional
type OrderUpdatePayload = Partial<Pick<Order, 'status'>> & {
notes?: string; // Allow extra fields
};
// Usage
async function updateOrder(id: string, payload: OrderUpdatePayload) {
if (payload.status) {
// Type-safe status update
}
}Configuration maps: Record with union keys
type Environment = 'development' | 'staging' | 'production';
type Config = Record<Environment, {
apiUrl: string;
logLevel: 'debug' | 'info' | 'error';
maxRetries: number;
}>;
const config: Config = {
development: {
apiUrl: 'http://localhost:3000',
logLevel: 'debug',
maxRetries: 3,
},
staging: {
apiUrl: 'https://staging.api.com',
logLevel: 'info',
maxRetries: 5,
},
production: {
apiUrl: 'https://api.com',
logLevel: 'error',
maxRetries: 10,
},
};Handler registries: Record with function types
In an event-driven system, I often map event types to handlers.
type EventType = 'user.created' | 'order.shipped' | 'payment.failed';
type EventHandler<T extends EventType> = (event: EventData<T>) => Promise<void>;
type EventRegistry = Record<EventType, EventHandler<EventType>>;
// This enforces that every event type has a handler
const handlers: EventRegistry = {
'user.created': async (event) => {
// Handle user creation
},
'order.shipped': async (event) => {
// Handle order shipment
},
'payment.failed': async (event) => {
// Handle payment failure
},
};Common mistakes
- Using Partial everywhere:
Partial<T>is for update payloads, not query responses. If a user is fetched from the database, all properties are present—use the full type. - Forgetting to Omit sensitive fields: It's easy to expose a password or API key accidentally. Make
Omita habit for any public-facing type. - Mismatching Record keys: If you define
Record<PaymentMethod, Handler>but then only provide handlers for some methods, TypeScript will catch it—trust the error and add the missing cases. - Over-nesting utility types:
Partial<Omit<Pick<User, 'x'>, 'y'>>>is hard to read. Extract intermediate types:type UserBase = Pick<User, 'x'>; type UserUpdatePayload = Partial<Omit<UserBase, 'y'>>; - Duplicating what Omit does: Instead of manually rewriting a type and deleting fields, use
Omit. It's clearer and stays in sync.
Key takeaways
Partial<T>makes all properties optional—use it for PATCH payloads and updates.Pick<T, K>selects specific properties;Omit<T, K>removes them—use these for different views of the same entity.Record<K, V>creates a fully-typed dictionary—enforce completeness in configs, handlers, and lookup tables.ReturnType<F>andAwaited<T>extract types from functions and promises, keeping your types synchronized with reality.- Derive types from your domain model instead of writing them manually—single source of truth prevents bugs.
- Combine utility types to build precise payloads:
Partial<Pick<T, K>>for flexible updates.






Comments
Loading comments…