Утилитные типы TypeScript — расширенное руководство

Мнемоническая таблица по категориям

Объектные утилитные типы

Изменяют структуру объекта

Утилитный типМнемоникаЧто делает
Partial<T>"Частичный"Делает все свойства необязательными
Required<T>"Обязательный"Делает все свойства обязательными
Pick<T, K>"Выбрать"Выбирает указанные свойства
Omit<T, K>"Опустить"Исключает указанные свойства
Record<K, T>"Запись в БД"Создает {} с ключами K и значениями T
Readonly<T>"Только чтение"Делает все свойства только для чтения

Функциональные утилитные типы

Работают с функциями

Утилитный типМнемоникаЧто делает
ReturnType<T>"Тип выхода"Извлекает тип возвращаемого значения
Parameters<T>"Тип входа"Извлекает типы параметров функции
OmitThisParameter<T>"Без контекста"Удаляет this-параметр
ThisParameterType<T>"Тип контекста"Извлекает тип this-параметра

Утилитные типы для работы с классами

Утилитный типМнемоникаЧто делает
InstanceType<T>"Что возвращает new"Тип экземпляра класса
ConstructorParameters<T>"Что принимает new"Типы параметров конструктора

Утилитные типы для фильтрации типов

Утилитный типМнемоникаЧто делает
NonNullable<T>"Не нулевой"Удаляет null и undefined из типа
Exclude<T, U>"Исключить"Исключает типы из T, присутствующие в U
Extract<T, U>"Извлечь"Извлекает из T типы, присутствующие в U

Утилитные типы для контекста this

Утилитный типМнемоникаЧто делает
ThisType<T>"Маркер контекста"Маркирует тип this в объектах

Использование typeof с утилитными типами

Многие утилитные типы TypeScript обычно используются в сочетании с оператором typeof. Это не просто стилистический выбор, а необходимость, которая обусловлена типовой системой TypeScript.

ReturnType и Parameters

// Для функций почти всегда нужен typeof
function createUser() { 
  return { id: 1, name: "John" }; 
}

// Правильно - с typeof
type User = ReturnType<typeof createUser>;
// { id: number; name: string; }

// Неправильно - без typeof
// type User = ReturnType<createUser>; // Ошибка: 'createUser' refers to a value, but is being used as a type here

При использовании Parameters результат возвращается в виде кортежа (tuple):

function fetchData(url: string, options: { method: string; headers?: Record<string, string> }) {
  // реализация
}

type FetchParams = Parameters<typeof fetchData>;
// [url: string, options: { method: string; headers?: Record<string, string> }]

// Можно обращаться к параметрам по индексу
type OptionsType = FetchParams[1]; // { method: string; headers?: Record<string, string> }

InstanceType и ConstructorParameters

С классами ситуация аналогична - почти всегда требуется использовать typeof:

class User {
  constructor(public id: number, public name: string) {}
  greet() { return `Hello, ${this.name}`; }
}

// Правильно - с typeof
type UserInstance = InstanceType<typeof User>;
// { id: number; name: string; greet(): string; }

// Неправильно - ошибка компиляции
// type UserInstance = InstanceType<User>; // 'User' refers to a value, but is being used as a type

// То же самое с ConstructorParameters
type UserConstructorParams = ConstructorParameters<typeof User>;
// [id: number, name: string]

Важно понимать разницу между InstanceType и ConstructorParameters:

  • InstanceType<typeof Class> - тип экземпляра класса (тип объекта, создаваемого через new Class())
  • ConstructorParameters<typeof Class> - типы параметров конструктора (параметры, передаваемые в new Class(...))

Более точное объяснение:

  • ConstructorParameters<typeof User> = [number, string] - это tuple типов параметров конструктора
  • InstanceType<typeof User> = User - это тип экземпляра класса

ThisParameterType

ThisParameterType извлекает тип this-параметра функции, когда он явно указан:

// Функция с явным this-параметром
function greet(this: { name: string }, prefix: string) {
  return `${prefix}, ${this.name}!`;
}

// Получаем тип this-параметра
type GreetThisType = ThisParameterType<typeof greet>; // { name: string }

// Использование
const user = { name: "Alice", age: 30 };
greet.call(user, "Привет"); // OK

const invalid = { age: 25 };
// greet.call(invalid, "Привет"); // Ошибка: объект не имеет свойства 'name'

Причины использования typeof

Необходимость использования typeof с утилитными типами вроде ReturnTypeParametersInstanceType связана с фундаментальным различием между значениями и типами в TypeScript:

  1. Две отдельные сферы существования:
    • Значения (функции, переменные, классы как конструкторы) - существуют во время выполнения
    • Типы (интерфейсы, типы) - существуют только во время компиляции
  2. Преобразование между мирами:
    • typeof в контексте типов - преобразует значение в его тип
    • type User = typeof user - получает тип значения user
  3. Почему это важно:
// Функция как значение
function process() { return 42; }

// Интерфейс как тип
interface Config { debug: boolean; }

// Утилитные типы работают с ТИПАМИ, а не ЗНАЧЕНИЯМИ
type ProcessReturnType = ReturnType<typeof process>; // OK: number
// type InvalidType = ReturnType<process>; // Ошибка: process - это значение, а не тип

type ConfigKeys = keyof Config; // OK: "debug"
// type InvalidKeys = keyof process; // Ошибка: process - это значение, а не тип

Эта концепция разделения значений и типов - одна из ключевых особенностей TypeScript. Она позволяет системе типов быть выразительной, не влияя на выполнение программы.

Запомните правило: если вы работаете с функцией, классом или другим значением в контексте типов, почти всегда нужно использовать typeof

Комбинирование утилитных типов с примерами

Пример 1: ReadonlyUserBasicInfo

// Начальный тип
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  lastLogin: Date;
}

// Комбинирование: Pick + Readonly
type ReadonlyUserBasicInfo = Readonly<Pick<User, 'id' | 'name'>>;

// Эквивалентно:
// {
//   readonly id: number;
//   readonly name: string;
// }

const userInfo: ReadonlyUserBasicInfo = { id: 1, name: "Иван" };
// userInfo.name = "Петр"; // Ошибка: Cannot assign to 'name' because it is a read-only property

Пример 2: SafeApiResult

// Функция, которая может вернуть null
function fetchData(): { data: string[] } | null {
  // ...
  return Math.random() > 0.5 ? { data: ["abc"] } : null;
}

// Комбинирование: ReturnType + NonNullable
type SafeApiResult = NonNullable<ReturnType<typeof fetchData>>;

// Эквивалентно:
// {
//   data: string[];
// }

// Использование:
function processSafeData(data: SafeApiResult) {
  // Здесь не нужна проверка на null
  console.log(data.data.length);
}

Пример 3: OptionalUserParams

// Класс с конструктором
class UserManager {
  constructor(
    userId: number,
    options: {
      includeDetails: boolean
      fetchRoles: boolean
      cacheTTL: number
    }
  ) {
    // ...
  }
}

// Комбинирование: ConstructorParameters + Partial
type UserManagerParams = ConstructorParameters<typeof UserManager>
type OptionalUserManagerOptions = Partial<UserManagerParams[1]>

// Эквивалентно:
// {
//   includeDetails?: boolean;
//   fetchRoles?: boolean;
//   cacheTTL?: number;
// }

// Функция с опциональными параметрами
function createUserManager(userId: number, options?: OptionalUserManagerOptions) {
  const defaultOptions = {
    includeDetails: false,
    fetchRoles: false,
    cacheTTL: 3600
  }

  return new UserManager(
    userId,
    { ...defaultOptions, ...options }
  )
}

Создание собственных утилитных типов

Пример 1: DeepReadonly

Стандартный Readonly<T> делает только "поверхностную" заморозку объекта - вложенные объекты остаются изменяемыми:

interface User {
  id: number
  name: string
  settings: {
    theme: string
    notifications: boolean
  }
}

type ReadonlyUser = Readonly<User>
const user: ReadonlyUser = {
  id: 1,
  name: "Alice",
  settings: { theme: "dark", notifications: true }
}

// Ошибка - нельзя изменить свойство верхнего уровня
// user.name = "Bob";

// Но вложенный объект можно изменить!
user.settings.theme = "light" // Работает без ошибок

Создадим рекурсивный тип DeepReadonly, который замораживает все уровни вложенности:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K]
}

// Использование
type DeepReadonlyUser = DeepReadonly<User>
const deepUser: DeepReadonlyUser = {
  id: 1,
  name: 'Alice',
  settings: { theme: 'dark', notifications: true },
}

// Ошибка - нельзя изменить свойство верхнего уровня
// deepUser.name = "Bob";

// Теперь и вложенный объект нельзя изменить!
// deepUser.settings.theme = "light"; // Ошибка: Cannot assign to 'theme' because it is a read-only property

Разбор типа DeepReadonly:

  1. [K in keyof T] - перебираем все ключи типа T
  2. readonly - делаем каждое свойство только для чтения
  3. T[K] extends object ? ... : T[K] - проверяем, является ли значение объектом
  4. T[K] extends Function ? T[K] : DeepReadonly<T[K]> - если это функция, оставляем как есть, иначе рекурсивно применяем DeepReadonly
  5. Рекурсия позволяет обрабатывать вложенные объекты любой глубины

Пример 2: Nullable

Создадим тип, противоположный NonNullable - делающий тип допускающим null и undefined:

type Nullable<T> = T | null | undefined

// Использование
function fetchUser(id: number): Nullable<User> {
  // Может вернуть пользователя, null или undefined
  if (id < 0) return null
  if (id === 0) return undefined
  return { id, name: 'User ' + id }
}

// Теперь нужно проверять результат перед использованием
const user = fetchUser(5)
if (user) {
  console.log(user.name) // Безопасно, только если user не null/undefined
}

Разбор типа Nullable:

  1. T | null | undefined - объединяет исходный тип с null и undefined
  2. Это простое объединение типов (union type)
  3. Заставляет TypeScript требовать проверки на null/undefined перед использованием

Пример 3: DeepPartial

Стандартный Partial<T> делает необязательными только свойства верхнего уровня. Создадим рекурсивную версию:

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : T[K] extends Function
        ? T[K]
        : DeepPartial<T[K]>
    : T[K]
}

// Исходный сложный тип
interface Config {
  server: {
    host: string
    port: number
    ssl: {
      enabled: boolean
      cert: string
      key: string
    }
  }
  database: {
    url: string
    credentials: {
      username: string
      password: string
    }
  }
  features: string[]
}

// Использование DeepPartial для частичного обновления
function updateConfig(config: Config, updates: DeepPartial<Config>): Config {
  // Реализация слияния объектов
  return deepMerge(config, updates)
}

// Теперь можно передать только нужные поля на любом уровне вложенности
updateConfig(currentConfig, {
  server: {
    ssl: {
      enabled: true,
      // Не нужно указывать cert и key
    },
  },
  // Не нужно указывать database и features
})

Разбор типа DeepPartial:

  1. [K in keyof T]? - перебираем все ключи T и делаем их необязательными (?)
  2. Проверяем, является ли свойство объектом: T[K] extends object
  3. Специальная обработка для массивов: T[K] extends Array<infer U> с использованием infer для извлечения типа элементов
  4. Специальная обработка для функций: T[K] extends Function ? T[K]
  5. Рекурсивное применение DeepPartial для вложенных объектов

Оригинальный текст статьи gist.github.com/olegopro

Написать комментарий