5 TypeScript Conditional Types Patterns Every Developer Should Master
While reviewing a complex TypeScript codebase the other day, I realized that conditional types are one of those features that separate intermediate TypeScript developers from advanced ones. Let me show you the patterns that changed how I write type-safe code.
While reviewing a complex TypeScript codebase the other day, I realized that conditional types are one of those features that separate intermediate TypeScript developers from advanced ones. They're like the ternary operator for types—simple in concept but incredibly powerful when you understand their nuances.
When I first encountered conditional types, I saw them scattered throughout utility type definitions and thought, "I'll never need this." Little did I know that mastering this feature would unlock entirely new ways of thinking about type safety. In today's world, working smart is the way to go, and conditional types are one of those concepts that give you massive returns on your learning investment. If you're new to TypeScript's advanced features, I recommend starting with generics before diving into conditional types—they build on each other naturally.
In this post, we'll go over 5 practical patterns that will take your TypeScript game to the next level. These aren't theoretical exercises—they're patterns I use in production code every single day.
1. Basic Syntax and When to Use It
Let's start with the fundamentals. The syntax for conditional types looks like this:
T extends U ? X : YIf you're like me, you probably looked at this and thought, "Great, another syntax to memorize." But here's the thing: once you see it in action, everything clicks.
Here's a real-world scenario I came across recently. I had a function that could accept either a string ID or a full user object, and I needed the return type to match:
// Bad - return type is too broad
function getUser(input: string | User): User | Promise<User> {
if (typeof input === 'string') {
return fetchUser(input) // Returns Promise<User>
}
return input // Returns User
}The problem? TypeScript can't tell you which return type you're getting without runtime checks everywhere. Luckily we can use conditional types to make this type-safe:
// Better - return type adapts to input
type GetUserReturn<T> = T extends string ? Promise<User> : User
function getUser<T extends string | User>(input: T): GetUserReturn<T> {
if (typeof input === 'string') {
return fetchUser(input) as GetUserReturn<T>
}
return input as GetUserReturn<T>
}
// Now TypeScript knows!
const user = getUser({ id: '123', name: 'Chris' }) // Type: User
const asyncUser = getUser('123') // Type: Promise<User>Key takeaway: Conditional types let you create dynamic type relationships based on input types. Use them when your function's behavior changes based on the type of input.
2. Building Custom Utility Types
One of the most powerful applications of conditional types is creating your own utility types. Make no mistake about it—the built-in TypeScript utilities like Exclude, Extract, and NonNullable are all implemented with conditional types. If you want to learn more about the built-in utilities, check out my guide on 10 TypeScript utility types for bulletproof code.
I was once guilty of writing repetitive type transformations all over my codebase. Then I realized I could extract these patterns into reusable utilities. Here's an example that came up when working with deeply nested API responses:
// Bad - manually unwrapping array types everywhere
type UserArray = User[]
type User = UserArray[number] // Extracting the element type
type PostArray = Post[]
type Post = PostArray[number]
// This gets old fast...Instead, let's create a generic Flatten utility:
// Better - reusable utility type
type Flatten<T> = T extends Array<infer U> ? U : T
type User = Flatten<User[]> // User
type Post = Flatten<Post[]> // Post
type Primitive = Flatten<string> // string (no-op for non-arrays)Here's another one I use constantly when dealing with optional properties:
type RequiredKeys<T> = {
[K in keyof T]-?: T[K] extends Required<T>[K] ? K : never
}[keyof T]
interface Config {
apiKey: string
timeout?: number
retries?: number
}
type MustProvide = RequiredKeys<Config> // "apiKey"Key takeaway: If you're writing the same type transformation more than twice, extract it into a utility type. Trust me, your future self will thank you.

3. The Power of the infer Keyword
This is where conditional types go from "useful" to "mind-blowing." The infer keyword lets you extract types from within other types. I cannot stress this enough—once you understand infer, you'll start seeing opportunities to use it everywhere.
Let's say you want to extract the return type of any function. Without infer, you'd need a different utility for every function signature:
// Bad - limited and inflexible
type GetStringFunctionReturn<T extends () => string> = string
type GetNumberFunctionReturn<T extends () => number> = number
// This doesn't scale...With infer, we can create a generic solution:
// Better - works with any function
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function fetchUser(): Promise<User> {
return fetch('/user').then(r => r.json())
}
function getCount(): number {
return 42
}
type UserReturn = GetReturnType<typeof fetchUser> // Promise<User>
type CountReturn = GetReturnType<typeof getCount> // numberHere's another pattern I use when working with Promise-based APIs:
type Unwrap<T> = T extends Promise<infer U> ? U : T
type User = Unwrap<Promise<User>> // User
type Data = Unwrap<Promise<Promise<string>>> // Promise<string> (one level only)
// For deep unwrapping
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T
type Resolved = DeepUnwrap<Promise<Promise<Promise<number>>>> // numberKey takeaway: Use infer when you need to extract a type from within a larger type structure. It's perfect for working with function signatures, promises, and complex nested types.
4. Distributive Conditional Types Over Union Types
In other words, when conditional types are applied to union types, they distribute over each member. This behavior is incredibly powerful but can be confusing at first.
Recently, there was a project where I needed to filter out certain types from a union. Here's what I tried initially:
// Bad - doesn't distribute as expected
type RemoveStrings<T> = T extends string ? never : T
type Mixed = string | number | boolean
type Filtered = RemoveStrings<Mixed> // never (wrong!)The problem is that the union is checked as a whole. Luckily we can fix this by ensuring distribution:
// Better - distributes over each union member
type RemoveStrings<T> = T extends string ? never : T
// Manual distribution
type Filtered = RemoveStrings<string> | RemoveStrings<number> | RemoveStrings<boolean>
// Result: never | number | boolean = number | boolean
// TypeScript does this automatically when you use it correctly
type FilterStrings<T> = T extends string ? never : T
type Result = FilterStrings<string | number | boolean> // number | booleanHere's a practical example I use for filtering nullable values:
type NonNullable<T> = T extends null | undefined ? never : T
type MaybeString = string | null | undefined
type DefinitelyString = NonNullable<MaybeString> // string
type MixedValues = string | number | null | boolean | undefined
type CleanValues = NonNullable<MixedValues> // string | number | booleanBoy, not everything in life goes in our favor, but distributive conditional types make filtering unions a breeze!
Key takeaway: Conditional types automatically distribute over union types. Use this to filter, transform, or extract specific types from unions.

5. Real-World Pattern: Type-Safe API Responses
Let me show you one of my favorite real-world patterns that combines everything we've covered. When working with APIs, you often need to handle success and error states:
// Bad - unclear which properties exist when
interface ApiResponse {
success: boolean
data?: User
error?: string
}
function handleResponse(response: ApiResponse) {
if (response.success) {
console.log(response.data.name) // Error: data might be undefined
}
}This pattern forces you to add optional chaining everywhere. Let's use conditional types to make it type-safe:
// Better - discriminated union with conditional types
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string }
type ApiResult<T> = T extends { success: infer S }
? S extends true
? T & { data: any }
: T & { error: string }
: never
function handleResponse(response: ApiResponse<User>) {
if (response.success) {
console.log(response.data.name) // TypeScript knows data exists!
} else {
console.error(response.error) // TypeScript knows error exists!
}
}
// Even better - extract data type from response
type ExtractData<T> = T extends ApiResponse<infer U> ? U : never
type UserData = ExtractData<ApiResponse<User>> // UserHere's the pattern I use for form validation states:
type FormState<T> =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'valid'; data: T }
| { status: 'invalid'; errors: Record<keyof T, string> }
function handleFormState<T>(state: FormState<T>) {
if (state.status === 'valid') {
console.log(state.data) // TypeScript knows this is T
} else if (state.status === 'invalid') {
console.log(state.errors) // TypeScript knows this is Record<keyof T, string>
}
}Key takeaway: Conditional types excel at modeling state machines and discriminated unions. Use them to make impossible states unrepresentable in your types.
Conclusion
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!
Conditional types might seem intimidating at first, but they're one of those features where the investment pays off exponentially. Start with simple use cases like pattern 1, then gradually work your way up to more complex patterns.
The point is: once you master conditional types, you'll start seeing TypeScript not as a type checker but as a type generator. Your types become smarter, your code becomes safer, and your teammates will sleep better at night knowing the type system has their back.
Trust me, when you start using these patterns in production, it's noticeably different in a positive way. You'll catch bugs at compile time that would have otherwise slipped into production, and your IDE autocomplete will feel like it's reading your mind.
What conditional type pattern are you most excited to try? Let me know on Twitter/X!
Related Posts
If you enjoyed this guide, you might also like:
- Generics in TypeScript - Master the foundation that makes conditional types possible
- 10 TypeScript Utility Types for Bulletproof Code - Learn the built-in utilities powered by conditional types
- The Power of TypeScript's Satisfies Operator - Another advanced TypeScript feature for type safety
Photo credits: Pixabay and Mikhail Nilov on Pexels