TypeScript's type system not only prevents runtime errors but also provides significant advantages in code maintenance, scalability, and readability. Through advanced type features like Conditional Types, Mapped Types, and Utility Types, we can express complex constraints and transformations at the type level.
In this article, we'll explore these powerful tools in TypeScript 5.x with practical examples, warnings, and best practices.
Why Are Advanced Types Important?
As applications grow, maintaining type safety becomes more challenging. Advanced types help with:
- Eliminating repetitive type definitions
- Making type inference from existing structures
- Creating reusable, highly flexible APIs
- Improving developer experience with autocomplete and error feedback
Let's get started!
1. Conditional Types
Conditional types allow us to express logic at the type level:
type MyConditional<T> = T extends U ? X : Y;
- If
T
extendsU
, the result isX
, otherwise it'sY
.
Type Inference with infer
The infer
keyword allows us to infer a type within a conditional type:
type FirstArgument<T> = T extends (arg1: infer A, ...args: any[]) => any
? A
: never;
type Example = FirstArgument<(x: number, y: string) => void>;
// Example = number
A more complex example:
type ArrayElementType<T> = T extends (infer U)[] ? U : never;
type NumArrayElement = ArrayElementType<number[]>; // number
type StrArrayElement = ArrayElementType<string[]>; // string
type NotArray = ArrayElementType<boolean>; // never
Distributive Conditional Types
When applied to union types, conditional types are automatically distributed:
type ToString<T> = T extends string ? string : never;
type Result = ToString<"a" | 3 | "b">;
// Result = string | never | string = string
To prevent distribution:
type NotDistributed<T> = [T] extends [string] ? true : false;
type DistributedResult = ToString<"a" | 3>; // string
type NotDistributedResult = NotDistributed<"a" | 3>; // false
Real World Examples
-
Exclude
andExtract
:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type T0 = Exclude<"a" | "b" | "c", "a" | "b">; // "c"
type T1 = Extract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
- Return type inference:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { name: "John", age: 30 };
}
type User = ReturnType<typeof getUser>; // { name: string; age: number }
- Extracting type from Promise:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type StringPromise = Promise<string>;
type Unpacked = UnpackPromise<StringPromise>; // string
2. Mapped Types
Mapped types allow us to transform properties of an existing type.
type OptionsFlags<T> = {
[K in keyof T]: boolean;
};
interface FeatureFlags {
darkMode: () => void;
newUserProfile: () => void;
}
type FeatureOptions = OptionsFlags<FeatureFlags>;
// { darkMode: boolean; newUserProfile: boolean; }
Modifiers
We can add or remove modifiers:
interface Person {
name: string;
age?: number;
}
type ReadonlyPerson = {
readonly [K in keyof Person]: Person[K];
};
// { readonly name: string; readonly age?: number; }
type RequiredPerson = {
[K in keyof Person]-?: Person[K];
};
// { name: string; age: number; }
-
+?
/-?
→ makes properties optional / required -
+readonly
/-readonly
→ makes properties readonly / mutable
Key Remapping
Since TypeScript 4.1, we can remap keys with as
:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
/*
{
getName: () => string;
getAge: () => number;
}
*/
A more complex example:
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, "kind">]: T[K];
};
interface Circle {
kind: "circle";
radius: number;
}
type KindlessCircle = RemoveKindField<Circle>;
// { radius: number; }
3. Utility Types
TypeScript comes with many built-in utility types that combine the logic of conditional and mapped types.
Common Examples
-
Partial<T>
— all properties optional -
Required<T>
— all properties required -
Readonly<T>
— all properties readonly -
Pick<T, K>
— select specific properties -
Omit<T, K>
— exclude specific properties -
Record<K, T>
— create a type with K keys and T values -
ReturnType<F>
— extract return type of a function -
Parameters<F>
— extract parameter types of a function -
NonNullable<T>
— exclude null and undefined
Behind the Scenes
Most utility types are implemented using conditional + mapped types:
// Partial<T>
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Required<T>
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Pick<T, K>
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Omit<T, K>
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// Exclude<T, U>
type Exclude<T, U> = T extends U ? never : T;
Practical Usage Examples
// For partial updates
interface User {
id: number;
name: string;
email: string;
age: number;
}
function updateUser(id: number, updates: Partial<User>) {
// Update user
}
// Create new object with only specific properties
type UserPreview = Pick<User, "id" | "name">;
// Remove sensitive data
type PublicUser = Omit<User, "email" | "age">;
// Create key-value pairs
type Pages = "home" | "about" | "contact";
type PageInfo = Record<Pages, { title: string; url: string }>;
4. Combining Conditional and Mapped Types
We can create custom utility types by combining these features.
DeepReadonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Company {
name: string;
address: {
street: string;
city: string;
};
}
type ReadonlyCompany = DeepReadonly<Company>;
/*
{
readonly name: string;
readonly address: {
readonly street: string;
readonly city: string;
};
}
*/
Key Transformation
type PrefixKeys<T, Pref extends string> = {
[K in keyof T as `${Pref}${Capitalize<string & K>}`]: T[K];
};
interface UserSettings {
darkMode: boolean;
notifications: boolean;
}
type PrefixedSettings = PrefixKeys<UserSettings, "user">;
/*
{
userDarkMode: boolean;
userNotifications: boolean;
}
*/
Value-Based Filtering
type FilterByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
interface Example {
id: number;
name: string;
active: boolean;
createdAt: Date;
}
type OnlyStrings = FilterByValue<Example, string>; // { name: string }
type OnlyDates = FilterByValue<Example, Date>; // { createdAt: Date }
Conditional Properties
type ConditionalProps<T> = {
[K in keyof T]: T[K] extends string ? { value: T[K]; length: number } : T[K];
};
interface Data {
name: string;
age: number;
email: string;
}
type TransformedData = ConditionalProps<Data>;
/*
{
name: { value: string, length: number };
age: number;
email: { value: string, length: number };
}
*/
5. Practical Example: Configurable Form Fields
Consider a form model where fields can be readonly or optional based on configuration:
interface FormFields {
id: number;
name: string;
email: string;
dateOfBirth?: Date;
}
type FieldConfig = {
readonly?: boolean;
optional?: boolean;
};
type ConfigureFields<T, C extends FieldConfig> = {
[K in keyof T]: C["readonly"] extends true
? readonly T[K]
: C["optional"] extends true
? T[K] | undefined
: T[K];
};
// Usage:
type ReadonlyForm = ConfigureFields<FormFields, { readonly: true }>;
/*
{
readonly id: number;
readonly name: string;
readonly email: string;
readonly dateOfBirth?: Date;
}
*/
type PartialForm = ConfigureFields<FormFields, { optional: true }>;
/*
{
id?: number | undefined;
name?: string | undefined;
email?: string | undefined;
dateOfBirth?: Date | undefined;
}
*/
// More complex example: Form validation
type ValidationRules<T> = {
[K in keyof T as `${string & K}Validation`]?: (value: T[K]) => boolean;
};
type ValidatedForm<T> = T & ValidationRules<T>;
const userForm: ValidatedForm<FormFields> = {
id: 1,
name: "John",
email: "john@example.com",
nameValidation: (value: string) => value.length > 2,
emailValidation: (value: string) => value.includes("@"),
};
6. What's New in TypeScript 5.x
TypeScript 5.x brought several improvements to advanced type features:
Template Literal Type Improvements
Better key remapping and string templates:
type EventName<T extends string> = `${T}Changed`;
type Concat<S1 extends string, S2 extends string> = `${S1}${S2}`;
type T0 = EventName<"foo">; // 'fooChanged'
type T1 = Concat<"Hello", "World">; // 'HelloWorld'
The satisfies
Operator
Verifies that values conform to type constraints without widening the type:
type Colors = "red" | "green" | "blue";
const myColors = {
red: "#FF0000",
green: "#00FF00",
blue: "#0000FF",
yellow: "#FFFF00", // Error: yellow is not in Colors type
} satisfies Record<Colors, string>;
// myColors.yellow is not accessible - type error
Enhanced Inference
Conditional + infer
patterns became smarter in TypeScript 5.x:
type FirstOfArray<T extends any[]> = T extends [infer First, ...any[]]
? First
: never;
type First = FirstOfArray<[string, number, boolean]>; // string
Performance Improvements
Recursive mapped types compile faster in TypeScript 5.x:
// This type that was slower in previous versions is now faster
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
const
Type Parameters
This feature introduced in TypeScript 5.0 enables more accurate inference:
function getValues<T>(args: { values: T[] }): T[] {
return args.values;
}
// Before: string[]
// After: ["a", "b", "c"] (with 5.0)
const result = getValues({ values: ["a", "b", "c"] });
7. Best Practices and Recommendations
Managing Complexity
Break complex conditional types into smaller utility types:
// Complex
type ComplexType<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: "other";
// Better: break into pieces
type StringType<T> = T extends string ? "string" : never;
type NumberType<T> = T extends number ? "number" : never;
type BooleanType<T> = T extends boolean ? "boolean" : never;
type OtherType<T> = T extends string | number | boolean ? never : "other";
type BetterComplexType<T> =
| StringType<T>
| NumberType<T>
| BooleanType<T>
| OtherType<T>;
Consider Performance
Deep recursive types can affect compilation performance:
// Instead of deep recursion:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// Consider using a practical depth limit:
type DeepReadonlyLevel1<T> = {
readonly [K in keyof T]: T[K];
};
type DeepReadonlyLevel2<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonlyLevel1<T[K]>
: T[K];
};
Use Utility Types Whenever Possible
Don't reinvent the wheel:
// Don't reinvent:
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
// Use the built-in Partial:
type UserPartial = Partial<User>;
Document Your Types
Document your custom types with comments:
/**
* Makes all properties of an object (deeply) readonly
* @template T - The type to transform
*/
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
Avoid Over-Engineering
Don't try to solve everything with the type system. Sometimes simple solutions are better:
// Over-engineered:
type OverEngineered<T> = T extends string
? `String: ${T}`
: T extends number
? `Number: ${T}`
: T extends boolean
? `Boolean: ${T}`
: never;
// Simpler:
function formatValue(value: string | number | boolean): string {
return `${typeof value}: ${value}`;
}
8. Other Related Features in TypeScript 5.x
Template Literal Improvements
type Schema = {
name: string;
age: number;
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type SchemaGetters = Getters<Schema>;
// { getName: () => string; getAge: () => number; }
Type Narrowing with in
Operator
interface Dog {
bark(): void;
}
interface Cat {
meow(): void;
}
function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark(); // Type narrowed to Dog
} else {
animal.meow(); // Type narrowed to Cat
}
}
Smart Usage of unknown
and never
Types
// A function that never returns a value
function throwError(message: string): never {
throw new Error(message);
}
// Safely parse unknown type
function safeParse(json: string): unknown {
return JSON.parse(json);
}
const result = safeParse('{"name": "John"}');
if (typeof result === "object" && result !== null && "name" in result) {
console.log(result.name); // Safe access
}
Conclusion
Advanced type features in TypeScript 5.x — conditional types, mapped types, and utility types — provide developers with incredible flexibility and safety.
By combining them, you can:
- Reduce repetitive type declarations
- Ensure type safety in complex APIs
- Create codebases that are easier to maintain and scale
When used wisely, these tools help you write safer, more maintainable, and more readable code.
👉 Next time you're refactoring or designing an API, ask yourself: Can this be expressed as a type transformation?
Probably, conditional + mapped types will save you.
Mastering TypeScript's advanced type system takes time and practice, but it's worth the effort. It enables you to write safer, more sustainable, and more readable code.
Thanks for reading! If you have questions or experiences about TypeScript's advanced type features, feel free to share in the comments.
Top comments (0)