Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 042 / 100

Utility types you'll use weekly: lessons from production

Built-in type transformations.

Expona Admin9 min read

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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
// 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.

typescript
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

typescript
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.

typescript
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 Omit a 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> and Awaited<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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles