When I started working on frontend projects especially around the React ecosystem, I used Vanilla javascript. Typescript was getting the traction to be used on frontend projects. From that time on to 3 years down the line working with Typescript, I used to think that I am pretty good at it. But when I discovered utility types, I realized I'd been coding with one hand tied behind my back.
I'm working on a large-scale NextJs application. To paint a specific picture, we're talking 98.8% TypeScript (yes, we're that serious about types). For months before exploring the utility types, I was copying-pasting type definitions, wrestling with nullable API responses, building components that needed 50 different prop combinations just to handle a button that sometimes needs to be a link.
When working on a feature which involved GQL and integrating one of the custom components, I saw these types; became curious and decided to master them and voilà!! everything changed.
Today, I'm kicking off a 3-part series on the utility types I reach for every single day. Over these three articles, I'll share my learning around them and we'll look at real-world patterns from production code—not contrived tutorials. These aren't just fancy tricks; they are the workhorses that solve 80% of my type-related headaches.
In this first part, we’re focusing on the Foundational Sculptors: the four utilities you need to turn messy, unpredictable data into a rock-solid contract.
The Foundation: Sculpting Your Data
1. Record<K, T>
The Pain:
Every time I needed a lookup table or enum-like object, I'd write this:
// ❌ Before - No type safety, just vibes
const environments: { [key: string]: string } = {
DEVELOPMENT: 'DEVELOPMENT',
STAGING: 'STAGING',
TYPO: 'STAGGING', // Oops! No error, just a runtime bug waiting to happen
};
// Later in code...
const env = environments['PRODUTION']; // undefined, but TypeScript is chill about it
TypeScript accepts this, but if you analyze it, it's nothing but any with extra steps. The keys aren't validated. The values aren't validated. It's simply type-safety drama.
The Solution:
// ✅ After - Type-safe keys AND values
type Environment = 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION';
export const ENVIRONMENTS: Record<Environment, Environment> = {
DEVELOPMENT: 'DEVELOPMENT',
STAGING: 'STAGING',
PRODUCTION: 'PRODUCTION',
TYPO: 'DEV',
// ❌ TypeScript error: Type '"TYPO"' is not assignable to type 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION'
};
// Now this is also caught:
const ENVIRONMENTS: Record<Environment, Environment> = {
DEVELOPMENT: 'dev',
// ❌ Error: Type '"dev"' is not assignable to type 'Environment'
};
Real Use Cases:
- Configuration objects with known keys
- Enum-like lookups
- Mapping IDs to data structures
- Feature flags
When to use: You know all possible keys in advance and need type-safe values.
Ninja Tip - Level Up:
Combine Record with const arrays for maximum type safety:
const STATUSES = ['pending', 'approved', 'rejected'] as const;
type Status = (typeof STATUSES)[number];
// Result: 'pending' | 'approved' | 'rejected'
type StatusConfig = {
label: string;
color: string;
};
type StatusMap = Record<Status, StatusConfig>;
// Now TypeScript enforces you define ALL statuses:
const statusMap: StatusMap = {
pending: { label: 'Pending', color: 'yellow' },
approved: { label: 'Approved', color: 'green' },
// ❌ Error: Property 'rejected' is missing
};
Single source of truth + compile-time validation = chef's kiss. 👨🍳💋
2. NonNullable<T>
The Pain:
If you've worked with GraphQL or REST APIs, you know this pain:
// API response structure (everything is nullable, why not?)
type ApiResponse = {
data?: {
user?: {
posts?: {
title?: string;
}[];
};
};
};
// Accessing nested data becomes optional chaining hell:
const title = response?.data?.user?.posts?.[0]?.title;
// ^ ^ ^ ^ ^
// Question marks everywhere. My code looks confused.
// And the type is: string | undefined | undefined | undefined...
// (TypeScript is just as confused as my code looks)
Every time you access a property, you're playing Russian roulette with undefined.
Under the Hood: Thinking like a Typescript compiler
Before talking about the solution, I would like to bring our understanding on the same page. We would need to understand how NonNullable<T> works. For that let us take a detour and understand this with the perspective of typescript compiler.
Step 1: Understanding the union
As you're already aware that if you're defining something with ? or potentially null, you're creating a Union type. Think of it with a variable that represents a box which holds different "chocolates": type chocolateName = string | null | undefined
This means the box can contain a string(chocolateName), OR it can be empty (null), OR it hasn't even been touched(initialized) yet (undefined).
Step 2: The Filter Logic
NonNullable<T> is a "Filter." It looks at the above Union Type and says: "I will keep everything EXCEPT null and undefined."
To make my point more clear, we can think the definition of NonNullable<T> to be something like:
type NonNullable<T> = T extends null | undefined ? never : T;
Translation: "If the type T is null or undefined, throw it away (never). Otherwise, keep the type T.
Step 3: The "Unwrapping" Process
Let's look at our API example. If we have type User = { name?: string | null }, the type of User['name'] is technically string | null | undefined.
When you run type CleanName = NonNullable<User['name']>, the compiler performs this check:
- Is
stringnullorundefined? No. → Keep it. - Is
nullnullorundefined? Yes. → Discard it. - Is
undefinednullorundefined? Yes. → Discard it.
Result: CleanName is now just string.
Step 4: The "Deep Unwrapping" Pipeline
When you have a deeply nested object, you have to unwrap it layer by layer. You can't just jump to the bottom because if the "parent" is null, the "child" doesn't even exist yet.
Caveat: When you use NonNullable, you're making a promise to the compiler that I know this API contract says a particular property might be null, but by the time it gets here(onto the line number where you used it), it's not. If the API actually returns null at runtime, the compiler won't save your app from a crash. This is why we generally use NonNullable in conjunction with Input validator libraries like Zod to ensure our promise to the compiler is kept.
The Solution:
// Step 1: Create a helper alias (trust me on this)
type NN<T> = NonNullable<T>;
// Step 2: Chain unwrapping from the top down
type ApiData = NN<ApiResponse['data']>;
// ^^^ Removes | undefined | null
type UserData = NN<ApiData['user']>;
// ^^^ user is no longer optional
type PostsData = NN<UserData['posts']>;
// ^^^^^^^^ Get type of array elements
type Post = NN<PostsData[number]>;
type PostTitle = NN<Post['title']>;
// Final result: string ✨
// No question marks, no uncertainty, just clean string
Real Pattern from Production:
This is literally from my codebase (simplified and masked 😎):
import { GetUserPostsQuery } from './graphql.generated';
type NN<T> = NonNullable<T>;
type QueryData = NN<GetUserPostsQuery>;
type UserData = NN<QueryData['user']>;
type PostsData = NN<UserData['posts']>;
type Post = NN<PostsData[number]>;
// Now I can confidently use Post without any optional chaining
// It's guaranteed to have the shape I expect
When to use:
- API responses (GraphQL, REST, etc.)
- Any time you see
Type | null | undefinedand you KNOW it exists - Unwrapping deeply nested optional structures
Ninja Tip - Save Your Sanity:
Create the NN<T> alias at the top of your types file. Your future self (and your teammates) will thank you (just like I keep thanking my project architects):
// types/api.ts
export type NN<T> = NonNullable<T>;
// Now everywhere:
import type { NN } from '@/types/api';
type CleanData = NN<MessyApiResponse['data']>;
Three characters instead of fourteen. You're welcome. 😎
3. Pick<T, K> & Omit<T, K>
These two work together like salt and sugar. They're opposites that balance each other.
The Pain:
// You have a massive User type
interface User {
id: string;
name: string;
email: string;
password: string;
ssn: string;
creditCard: string;
address: string;
phone: string;
createdAt: Date;
updatedAt: Date;
lastLogin: Date;
}
// You need different versions for different contexts:
// - User profile (name, email only)
// - Public user (everything except sensitive data)
// - User DTO (no date objects)
// Copy-paste hell begins...
interface UserProfile {
name: string;
email: string;
}
// Every time User changes, update this manually 😭
The Solution:
Pick<T, K> - Extract only what you need
// Extract specific properties
type UserProfile = Pick<User, 'name' | 'email'>;
// Result: { name: string, email: string }
// Component props from larger type
interface ProductData {
id: string;
name: string;
price: number;
description: string;
images: string[];
inventory: number;
weight: number;
dimensions: { width: number; height: number; depth: number };
manufacturer: string;
// ... 20 more fields
}
// Your component only needs these 3
type ProductCardProps = Pick<ProductData, 'name' | 'price' | 'images'>;
function ProductCard({ name, price, images }: ProductCardProps) {
// Clean, focused component
}
Omit<T, K> - Remove what you don't want
// Remove sensitive fields
type PublicUser = Omit<User, 'password' | 'ssn' | 'creditCard'>;
// Result: User without the dangerous stuff
// API DTO (no Date objects, they don't serialize well)
type UserDTO = Omit<User, 'createdAt' | 'updatedAt' | 'lastLogin'>;
When to use:
-
Pick: Extracting a subset (component props, DTOs, focused views) -
Omit: Removing sensitive/unwanted fields (public APIs, sanitization)
The Marie Kondo Rule™:
"Does this property spark joy in this context? No?
Omitit."
Ninja Tip - They Compose:
Just like we use salt and sugar together to prepare delicious Gujarati Dal 😋
// Pick some fields, THEN omit from those
type EditableUserProfile = Omit<Pick<User, 'name' | 'email' | 'id'>, 'id'>;
// Result: { name: string, email: string }
// (We picked 3, then removed id)
// This is the same as:
type EditableUserProfile = Pick<User, 'name' | 'email'>;
// But sometimes the composed version is clearer for complex scenarios
Real-World Pattern:
// Base props
interface BaseButtonProps {
variant: 'primary' | 'secondary';
size: 'sm' | 'md' | 'lg';
onClick: () => void;
disabled: boolean;
}
// Icon button doesn't need size (icon is always sized to content)
type IconButtonProps = Omit<BaseButtonProps, 'size'>;
// Link button needs href, not onClick
type LinkButtonProps = Omit<BaseButtonProps, 'onClick'> & {
href: string;
};
Wrap Up
We've covered the four "sculpting" tools that will handle the majority of your daily TypeScript friction. Here is the quick-reference guide for when to reach for which tool:
| Utility | The Metaphor | Best Used For... |
|---|---|---|
Record<K, T> |
The Dictionary | Locking down lookup tables and config objects. |
NonNullable<T> |
The Filter | Cleaning up "Question Mark Staircase" API responses. |
Pick<T, K> |
The Salt | Extracting specific properties for focused UI components. |
Omit<T, K> |
The Sugar | Removing sensitive or unwanted fields from a large object. |
Final Thought: Trust Your Tools
Mastering these four utilities isn't just about writing less code—it's about writing predictable code. When you use NonNullable to unwrap an API response or Record to define your environment variables, you are removing the "guessing game" from your development process. You're moving the errors from your user's browser back to your code editor, where they belong.
What's Next in Part 2? 🚀
Now that we've mastered the art of "sculpting" existing data, it's time to move into automation and transformation.
In Part 2 of this series, we'll move beyond the basics to look at how we can make our types dynamic and reusable. We will cover:
-
Partial<T>: Why this is the secret weapon for unit testing and mock factories. -
Exclude<T, U>: The "Bouncer" for your state machines and Union types. -
ReturnType<T>&Parameters<T>: The magic of "stealing" types directly from your functions so you never have to write a manual interface again.
Found this helpful? Let me know in the comments—and if you use any of these patterns, I'd love to hear about it! 🚀
Until then, Pranipat🙏! ☮️
Top comments (0)