DEV Community

Serif COLAKEL
Serif COLAKEL

Posted on

Advanced Type Features in TypeScript 5.x: Conditional, Mapped, and Utility Types

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;
Enter fullscreen mode Exit fullscreen mode
  • If T extends U, the result is X, otherwise it's Y.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To prevent distribution:

type NotDistributed<T> = [T] extends [string] ? true : false;

type DistributedResult = ToString<"a" | 3>; // string
type NotDistributedResult = NotDistributed<"a" | 3>; // false
Enter fullscreen mode Exit fullscreen mode

Real World Examples

  • Exclude and Extract:
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"
Enter fullscreen mode Exit fullscreen mode
  • 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 }
Enter fullscreen mode Exit fullscreen mode
  • Extracting type from Promise:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type StringPromise = Promise<string>;
type Unpacked = UnpackPromise<StringPromise>; // string
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode
  • +? / -? → 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;
}
*/
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 }>;
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
*/
Enter fullscreen mode Exit fullscreen mode

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;
}
*/
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 };
}
*/
Enter fullscreen mode Exit fullscreen mode

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("@"),
};
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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"] });
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)