10 TypeScript Utility Types That Will Make Your Code Bulletproof
Master TypeScript's built-in utility types to eliminate type duplication, prevent bugs, and write maintainable code that scales. Learn Pick, Omit, Partial, and 7 more essential utilities with real-world examples.
While I was refactoring a user management system at work the other day, I came across something that made me cringe—a single User interface change required updating 10 different type definitions scattered across the codebase. Little did I know that TypeScript's utility types could have prevented this entire mess, and when I finally decided to learn them properly, it completely transformed how I approach type safety.
In today's world of rapidly changing requirements, maintaining duplicate type definitions is not just tedious—it's dangerous. Every time you modify a base type, you risk missing one of those copies, leading to subtle bugs that slip through TypeScript's compiler. I cannot stress this enough: utility types are the difference between writing types once and maintaining them forever.
In this post, we'll go over 10 essential TypeScript utility types that will eliminate type duplication, catch bugs before they reach production, and make your codebase genuinely bulletproof. Let's dive in.
1. Pick<T, K> - Extract What You Need
I was once guilty of creating separate interfaces for every variation of a user object. When you need to expose only specific properties from a type, Pick is your solution.
// Bad - Duplicate type definitions
interface User {
id: string
email: string
password: string
name: string
age: number
isAdmin: boolean
}
interface PublicUser {
id: string
name: string
age: number
}
// Now you maintain two places when User changes!Here's the problem: If you add a new field to User, do you need to add it to PublicUser? Maybe, maybe not. This uncertainty leads to bugs.
// Better - Single source of truth
interface User {
id: string
email: string
password: string
name: string
age: number
isAdmin: boolean
}
type PublicUser = Pick<User, 'id' | 'name' | 'age'>
// Changes to User automatically propagate!Wonderful! Now when User changes, PublicUser adapts automatically. This is especially powerful in API routes where you want to return sanitized data:
export async function GET(request: Request) {
const users: User[] = await db.users.findMany()
// Type-safe transformation
const publicUsers: PublicUser[] = users.map(user => ({
id: user.id,
name: user.name,
age: user.age
}))
return Response.json(publicUsers)
}When to use it: Any time you need a subset of properties from an existing type—API responses, public profiles, or read-only views.

2. Omit<T, K> - Remove What You Don't Need
The flip side of Pick is Omit—when it's easier to specify what you don't want rather than what you do. If you're like me, you've written plenty of form DTOs that exclude auto-generated database fields.
// Bad - Manually redefining everything
interface UserEntity {
id: string
createdAt: Date
updatedAt: Date
email: string
name: string
password: string
}
interface CreateUserDTO {
email: string
name: string
password: string
}
// Again, two places to maintain!In other words, you're creating a maintenance nightmare. Luckily we can use Omit to derive the DTO from the entity:
// Better - Derive from the entity
interface UserEntity {
id: string
createdAt: Date
updatedAt: Date
email: string
name: string
password: string
}
type CreateUserDTO = Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>
// Type-safe form handling
async function createUser(data: CreateUserDTO) {
const user = await db.users.create({
data: {
...data,
createdAt: new Date(),
updatedAt: new Date()
}
})
return user
}Make no mistake about it—this pattern scales beautifully. When you add a new field to UserEntity, you only need to decide: "Is this field provided by the user or generated by the system?"
When to use it: Form inputs, API request bodies, or any scenario where you need "most of the type, except these few fields."
3. Partial - Everything Optional
Recently, there was a colleague who maintained an "edit profile" form that accepted a complex user object where every single field was optional. The code was littered with optional properties that duplicated the main type.
// Bad - Duplicating optional properties
interface User {
name: string
email: string
bio: string
age: number
location: string
}
interface EditUserInput {
name?: string
email?: string
bio?: string
age?: number
location?: string
}
// Forgot to add a field? Bugs!The moral of the story? Partial makes all properties optional automatically:
// Better - Automatic optional properties
interface User {
name: string
email: string
bio: string
age: number
location: string
}
type EditUserInput = Partial<User>
// Safely handle partial updates
async function updateUser(id: string, updates: Partial<User>) {
const currentUser = await db.users.findUnique({ where: { id } })
const updatedUser = await db.users.update({
where: { id },
data: { ...currentUser, ...updates }
})
return updatedUser
}This is a done deal for PATCH endpoints, optional configuration objects, and any scenario where "some or none" of the fields might be provided. Trust me, when you use Partial, it's noticeably different in a positive way—no more manually adding question marks everywhere.
When to use it: Update operations, optional configuration, or progressive form wizards where fields are filled incrementally.
4. Required - No More Optionals
The opposite of Partial is Required—it makes all properties mandatory. This is incredibly useful when you start with optional fields but need to ensure completeness before proceeding.
// Start with optional config
interface AppConfig {
apiKey?: string
apiSecret?: string
environment?: 'development' | 'production'
debug?: boolean
}
type ValidatedConfig = Required<AppConfig>
function validateConfig(config: AppConfig): ValidatedConfig {
if (!config.apiKey) throw new Error('API key is required')
if (!config.apiSecret) throw new Error('API secret is required')
if (!config.environment) throw new Error('Environment is required')
// TypeScript knows all fields are present now
return config as ValidatedConfig
}
// Now you can safely access without null checks
function initializeApp(config: ValidatedConfig) {
console.log(config.apiKey.toUpperCase()) // No optional chaining needed!
console.log(config.environment.toUpperCase())
}Here is an example of what I mean: You accept optional input for flexibility, validate it, and return a fully-required version. This pattern works beautifully with form validators like Zod where you ensure completeness before saving.
When to use it: Validation layers, ensuring complete objects before database operations, or converting optional configs into required runtime values.
5. Record<K, T> - Type-Safe Dictionaries
If you've been learning TypeScript for a while, you've probably written objects like this more times than you can count:
// Bad - No type safety on keys or values
const userRoles: { [key: string]: string[] } = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
}
// TypeScript won't catch typos!
userRoles['adnim'] = ['read'] // Oops, typo but no errorLet's take a look at how Record provides compile-time safety:
// Better - Type-safe keys and values
type Role = 'admin' | 'user' | 'guest'
type Permission = 'read' | 'write' | 'delete'
const userRoles: Record<Role, Permission[]> = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
}
// TypeScript catches typos
// userRoles['adnim'] = ['read'] // ❌ Type error!This is incredibly powerful for configuration objects, lookup tables, and mapping enums to values. If you're like me, once you start using Record, you'll never go back to [key: string] again.
// Real-world: HTTP status code messages
const statusMessages: Record<number, string> = {
200: 'OK',
201: 'Created',
400: 'Bad Request',
401: 'Unauthorized',
404: 'Not Found',
500: 'Internal Server Error'
}
function getStatusMessage(code: number): string {
return statusMessages[code] || 'Unknown Status'
}When to use it: Lookup tables, configuration maps, enum-to-value mappings, or any object where both keys and values have specific types.

6. Exclude<T, U> - Filter Union Types
Some of us have probably encountered union types that are almost what we need, but include one or two values we want to exclude. That's where Exclude shines.
// Start with a broad type
type Status = 'idle' | 'loading' | 'success' | 'error'
// But this component shouldn't handle 'idle'
type ActiveStatus = Exclude<Status, 'idle'>
// Result: 'loading' | 'success' | 'error'
function handleActiveStatus(status: ActiveStatus) {
if (status === 'loading') {
console.log('Fetching data...')
} else if (status === 'success') {
console.log('Data loaded!')
} else {
console.log('Error occurred')
}
// 'idle' is not a valid input!
}This is particularly useful when working with library types that are too broad for your use case:
// Remove nullable types
type NonNullable<T> = Exclude<T, null | undefined>
type UserId = string | null | undefined
type ValidUserId = NonNullable<UserId> // Result: string
// Real-world: Event types without deprecated events
type AllEvents = 'click' | 'focus' | 'blur' | 'legacy-event' | 'deprecated-handler'
type ModernEvents = Exclude<AllEvents, 'legacy-event' | 'deprecated-handler'>
// Result: 'click' | 'focus' | 'blur'When to use it: Filtering union types, removing null/undefined, or excluding deprecated values from legacy types.
7. Extract<T, U> - Keep Only What Matches
While Exclude removes types, Extract does the opposite—it keeps only the types that match. Think of it as a filter that passes through only specific values.
// Filter to keep only string types
type Mixed = string | number | boolean | null
type OnlyStrings = Extract<Mixed, string> // Result: string
// Real-world: Extract function types from a union
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| (() => void)
| string
type FunctionActions = Extract<Action, Function>
// Result: () => voidIn other words, Extract lets you narrow down union types to only the variants you care about. This is especially useful with discriminated unions:
type ApiResponse =
| { status: 'success'; data: string }
| { status: 'error'; message: string }
| { status: 'loading' }
// Extract only responses with a 'data' property
type SuccessResponse = Extract<ApiResponse, { status: 'success' }>
// Result: { status: 'success'; data: string }
function handleSuccess(response: SuccessResponse) {
console.log(response.data.toUpperCase()) // Safe!
}When to use it: Narrowing union types, extracting specific variants from discriminated unions, or filtering to keep only certain shapes.
8. ReturnType - Infer Function Returns
Recently I came across a codebase where API client functions returned complex nested types, and developers were manually duplicating those return types everywhere. Little did they know about ReturnType.
// Bad - Manually copying return type
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return {
id: data.id,
name: data.name,
email: data.email
}
}
// Duplicating the return type manually
type FetchUserResult = {
id: string
name: string
email: string
}
// Easy to get out of sync!Luckily we can extract the return type automatically:
// Better - Infer from the function
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return {
id: data.id,
name: data.name,
email: data.email
}
}
type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>
// Changes to fetchUser automatically update the type!
function displayUser(user: FetchUserResult) {
console.log(user.name, user.email)
}Note the Awaited utility—since fetchUser is async, ReturnType gives you Promise<{...}>, so we unwrap it. This pattern is powerful when working with API clients or any function where the implementation is the source of truth.
When to use it: Deriving types from existing functions, API client responses, or ensuring type consistency with function returns.
9. Parameters - Extract Function Parameters
Just like we can extract return types, we can extract parameter types too. This is incredibly useful for higher-order functions, decorators, or creating mocks.
// Original function with complex parameters
function createUser(
email: string,
name: string,
options: { role: 'admin' | 'user'; sendEmail: boolean }
) {
// Implementation
}
// Extract parameter types
type CreateUserParams = Parameters<typeof createUser>
// Result: [string, string, { role: 'admin' | 'user'; sendEmail: boolean }]
// Use for wrapper functions
function createUserWithDefaults(...args: CreateUserParams) {
const [email, name, options = { role: 'user', sendEmail: true }] = args
return createUser(email, name, options)
}Here is an example of what I mean: You can wrap functions while maintaining type safety without manually copying parameter signatures. This pattern shines with decorators and middleware:
// Real-world: Type-safe logging wrapper
function withLogging<T extends (...args: any[]) => any>(fn: T) {
return (...args: Parameters<T>): ReturnType<T> => {
console.log('Calling with args:', args)
const result = fn(...args)
console.log('Result:', result)
return result
}
}
const loggedCreateUser = withLogging(createUser)
// Full type safety preserved!When to use it: Wrappers, decorators, mock functions, or any higher-order function that needs to maintain the original function's parameter types.
10. Readonly - Immutable Types
The final utility type is Readonly, which makes all properties immutable. This is essential for preventing accidental mutations, especially in state management or configuration objects.
// Start with a mutable type
interface User {
name: string
email: string
age: number
}
// Make it immutable
type ImmutableUser = Readonly<User>
const user: ImmutableUser = {
name: 'Alice',
email: 'alice@example.com',
age: 30
}
// TypeScript prevents mutations
// user.age = 31 // ❌ Error: Cannot assign to 'age' because it is a read-only propertyMake no mistake about it—this is powerful for Redux state, React props, or any scenario where you want to ensure data isn't modified:
// Real-world: Redux state
interface AppState {
users: User[]
currentUser: User | null
isLoading: boolean
}
type ImmutableAppState = Readonly<AppState>
function reducer(state: ImmutableAppState, action: Action): ImmutableAppState {
// Can't accidentally mutate state
// state.isLoading = false // ❌ Error!
// Must return new object
return { ...state, isLoading: false }
}For deeply nested immutability, consider combining Readonly with recursive types, or use libraries like Immer that handle immutability at runtime as well.
When to use it: Redux/state management, configuration objects, React props, or any data structure that should never be modified after creation.

Combining Utility Types: Advanced Patterns
When I look back into my beginning stages learning TypeScript, I wish I had discovered earlier that utility types can be combined for powerful type transformations. Let's look at real-world patterns that build on what we've learned.
Pattern 1: Partial Updates with Pick
interface User {
id: string
email: string
name: string
age: number
preferences: {
theme: 'light' | 'dark'
notifications: boolean
}
}
// Allow updating only name and email, but both optional
type UpdateUserNameEmail = Partial<Pick<User, 'name' | 'email'>>
async function updateUserInfo(id: string, updates: UpdateUserNameEmail) {
const user = await db.users.update({
where: { id },
data: updates
})
return user
}Pattern 2: Create → Read → Update → Delete (CRUD) Types
interface UserEntity {
id: string
createdAt: Date
updatedAt: Date
email: string
password: string
name: string
age: number
}
// Create: Omit auto-generated fields
type CreateUserDTO = Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>
// Read (public): Pick safe fields
type PublicUser = Pick<UserEntity, 'id' | 'name' | 'email' | 'age'>
// Update: All fields optional except ID
type UpdateUserDTO = Partial<Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>>
// Delete: Just the ID
type DeleteUserDTO = Pick<UserEntity, 'id'>This pattern demonstrates the power of deriving multiple types from a single source of truth. When UserEntity changes, all derived types update automatically. Boy, not everything in life goes in our favor, but TypeScript's utility types sure make type maintenance easier!
Pattern 3: Type-Safe Form State
// Base form fields
interface LoginForm {
email: string
password: string
rememberMe: boolean
}
// Form state with validation errors
type FormState<T> = {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
isSubmitting: boolean
}
// Usage
const loginState: FormState<LoginForm> = {
values: {
email: '',
password: '',
rememberMe: false
},
errors: {
email: 'Invalid email format'
},
touched: {
email: true
},
isSubmitting: false
}In other words, by combining utility types with generics (check out our deep dive on generics in TypeScript), you can create reusable patterns that scale across your application.
Conclusion
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!
TypeScript's utility types transform how you manage types in large codebases. Instead of duplicating type definitions and manually keeping them synchronized, you define types once and derive variations automatically. This isn't just about convenience—it's about correctness and maintainability.
The point is this: When you use Pick, Omit, Partial, and the other utility types we covered, changes to your base types propagate automatically through your entire codebase. No more hunting down duplicate definitions. No more subtle bugs from forgetting to update one of ten copies. Just one source of truth and TypeScript ensuring everything stays consistent.
Working smart is the way to go—use utility types to eliminate type duplication at system boundaries (API routes, form DTOs, database models), and trust TypeScript to maintain type safety throughout your application. This layered approach gives you the best of both worlds: flexibility to change and confidence that nothing breaks.
Continue Learning:
- Generics in TypeScript - Master the generic types that power utility types
- TypeScript Form Validators with Zod - Advanced type inference and runtime validation
- 7 Practices to Write More Robustly - Broader code quality patterns
Photo Credits:
- TypeScript code by luis gomes on Pexels
- Developer workspace by Markus Spiske on Pexels
- Technology patterns by Google DeepMind on Pexels