← Курс/Generic ограничения: extends и keyof#163 из 257+30 XP

Generic ограничения: extends и keyof

Зачем нужны ограничения

Без ограничений generic-тип T может быть чем угодно, и TypeScript не позволит обращаться к его свойствам:

function getLength<T>(value: T): number {
  return value.length  // Ошибка TS: Property 'length' does not exist on type 'T'
}

// Решение — ограничить T типами у которых есть length:
function getLength<T extends { length: number }>(value: T): number {
  return value.length  // OK!
}

getLength('hello')       // 5 — string имеет length
getLength([1, 2, 3])     // 3 — array имеет length
// getLength(42)         // Ошибка TS: number не удовлетворяет { length: number }

T extends SomeType

extends задаёт минимальный контракт для T:

interface HasId {
  id: number
}

// T должен иметь поле id
function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id)
}

const users = [{ id: 1, name: 'Алексей' }, { id: 2, name: 'Ольга' }]
const found = findById(users, 1)  // { id: 1, name: 'Алексей' } — тип User, не HasId!

// Множественные ограничения через intersection:
function process<T extends HasId & { name: string }>(item: T): string {
  return `#${item.id}: ${item.name}`
}

keyof — ограничение ключами объекта

keyof T — тип, объединяющий все ключи T. Используется для безопасного обращения к свойствам:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]  // TypeScript знает точный тип T[K]
}

const user = { name: 'Алексей', age: 30, active: true }
const name   = getProperty(user, 'name')    // тип string
const age    = getProperty(user, 'age')     // тип number
// getProperty(user, 'email')  // Ошибка TS: 'email' не является ключом

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
  return { ...obj, [key]: value }
}

Условные возвращаемые типы

Ограничения позволяют TypeScript выводить точные типы результата:

// Возвращаемый тип зависит от переданного ключа
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key])
}

const users = [
  { name: 'Алексей', age: 30 },
  { name: 'Ольга',   age: 25 },
]

const names = pluck(users, 'name')  // string[] — TypeScript знает тип!
const ages  = pluck(users, 'age')   // number[]

Практический пример: типобезопасный маппер

// Преобразуем массив объектов в Map по ключу
function groupBy<T, K extends keyof T>(
  items: T[],
  key: K
): Map<T[K], T[]> {
  const map = new Map<T[K], T[]>()
  for (const item of items) {
    const k = item[key]
    const group = map.get(k) ?? []
    map.set(k, [...group, item])
  }
  return map
}

const orders = [
  { id: 1, status: 'pending', amount: 100 },
  { id: 2, status: 'done',    amount: 200 },
  { id: 3, status: 'pending', amount: 50  },
]

const byStatus = groupBy(orders, 'status')
// Map { 'pending' => [{...}, {...}], 'done' => [{...}] }

Constraint с default-значением

// T по умолчанию object, но можно передать другой тип
function createStore<T extends object = Record<string, unknown>>(
  initial: T
): { get(): T; set(updates: Partial<T>): void } {
  let state = { ...initial }
  return {
    get: () => ({ ...state }),
    set: (updates) => { state = { ...state, ...updates } }
  }
}

Примеры

Runtime реализация getProperty, pluck и groupBy — типобезопасные операции с объектами в стиле TypeScript generics

// TypeScript: function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]
// JS: реализуем с runtime-проверками

function getProperty(obj, key) {
  if (!Object.prototype.hasOwnProperty.call(obj, key)) {
    throw new RangeError(
      `Ключ "${String(key)}" не существует. Доступные: ${Object.keys(obj).join(', ')}`
    )
  }
  return obj[key]
}

function setProperty(obj, key, value) {
  if (!Object.prototype.hasOwnProperty.call(obj, key)) {
    throw new RangeError(`Ключ "${String(key)}" не существует в объекте`)
  }
  return { ...obj, [key]: value }
}

// TypeScript: function pluck<T, K extends keyof T>(items: T[], key: K): T[K][]
function pluck(items, key) {
  return items.map(item => {
    if (!Object.prototype.hasOwnProperty.call(item, key)) {
      throw new RangeError(`Элемент не имеет ключа "${String(key)}"`)
    }
    return item[key]
  })
}

// TypeScript: function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]>
function groupBy(items, key) {
  const map = new Map()
  for (const item of items) {
    const k = item[key]
    if (!map.has(k)) map.set(k, [])
    map.get(k).push(item)
  }
  return map
}

// TypeScript: function sortBy<T, K extends keyof T>(items: T[], key: K): T[]
function sortBy(items, key) {
  return [...items].sort((a, b) => {
    const va = a[key]
    const vb = b[key]
    if (va < vb) return -1
    if (va > vb) return 1
    return 0
  })
}

// --- Демонстрация ---

const users = [
  { id: 3, name: 'Виктор',  age: 35, role: 'user'  },
  { id: 1, name: 'Алексей', age: 30, role: 'admin' },
  { id: 4, name: 'Мария',   age: 28, role: 'user'  },
  { id: 2, name: 'Ольга',   age: 25, role: 'admin' },
]

console.log('=== getProperty ===')
console.log(getProperty(users[0], 'name'))  // 'Виктор'
console.log(getProperty(users[0], 'age'))   // 35

try {
  getProperty(users[0], 'email')  // ключ не существует
} catch (e) {
  console.log('Ошибка:', e.message)
}

console.log('\n=== setProperty (иммутабельный) ===')
const updated = setProperty(users[0], 'age', 36)
console.log('Обновлённый:', updated.age)  // 36
console.log('Оригинал:', users[0].age)   // 35 — не изменился

console.log('\n=== pluck ===')
const names = pluck(users, 'name')
console.log('Имена:', names)

const ages = pluck(users, 'age')
console.log('Возрасты:', ages)

console.log('\n=== groupBy ===')
const byRole = groupBy(users, 'role')
console.log('Администраторы:', byRole.get('admin').map(u => u.name))
console.log('Пользователи:', byRole.get('user').map(u => u.name))

console.log('\n=== sortBy ===')
const byAge  = sortBy(users, 'age')
const byName = sortBy(users, 'name')
console.log('По возрасту:', pluck(byAge, 'name'))
console.log('По имени:', pluck(byName, 'name'))