← Курс/Generics (Обобщённые типы)#159 из 257+35 XP

Generics — параметрические типы

Зачем нужны generics

Без generics приходится выбирать: писать код под конкретный тип или терять типобезопасность через any:

// Проблема: функция работает только со string
function firstString(arr: string[]): string {
  return arr[0]
}

// Антипаттерн: теряем типобезопасность
function firstAny(arr: any[]): any {
  return arr[0]  // возвращает any — TS не знает тип
}

// Решение: generic — T подставляется при вызове
function first<T>(arr: T[]): T {
  return arr[0]
}

const s = first(['a', 'b', 'c'])  // T = string, возвращает string
const n = first([1, 2, 3])        // T = number, возвращает number

Generics = пишем код один раз, работает с любым типом **без потери типобезопасности**.

Базовый синтаксис

// Generic функция
function identity<T>(arg: T): T {
  return arg
}

// Явное указание типа
const result1 = identity<string>('hello')  // T = string

// Вывод типа (type inference) — TypeScript сам определяет T
const result2 = identity(42)               // T = number (автоматически)

Generic интерфейсы и классы

// Generic интерфейс
interface Repository<T> {
  findById(id: number): T | undefined
  findAll(): T[]
  save(item: T): void
}

// Generic класс
class Stack<T> {
  private items: T[] = []

  push(item: T): void {
    this.items.push(item)
  }

  pop(): T | undefined {
    return this.items.pop()
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1]
  }

  get size(): number {
    return this.items.length
  }
}

const numStack = new Stack<number>()
numStack.push(1)
numStack.push(2)
console.log(numStack.pop())  // 2 — TypeScript знает что это number

Constraints — ограничения типа

Иногда нужно гарантировать что T имеет определённые свойства:

// T должен иметь поле length
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b
}

longest('hello', 'hi')      // OK — string имеет length
longest([1, 2, 3], [4, 5])  // OK — array имеет length
// longest(1, 2)             // Ошибка TS: number не имеет length

// keyof — ограничение ключами объекта
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: 'Алексей', age: 30 }
getProperty(user, 'name')  // string
getProperty(user, 'age')   // number
// getProperty(user, 'email') // Ошибка TS: 'email' не существует

Несколько параметров типа

function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
  return arr1.map((item, i) => [item, arr2[i]])
}

zip([1, 2, 3], ['a', 'b', 'c'])
// [[1, 'a'], [2, 'b'], [3, 'c']]

// Pair с двумя разными типами
class Pair<A, B> {
  constructor(public first: A, public second: B) {}

  swap(): Pair<B, A> {
    return new Pair(this.second, this.first)
  }
}

Параметры типа по умолчанию

// T = string если не указан явно
interface ApiResponse<T = string> {
  data: T
  status: number
}

const r1: ApiResponse = { data: 'hello', status: 200 }     // T = string
const r2: ApiResponse<number[]> = { data: [1, 2], status: 200 }  // T = number[]

Практика: generic утилиты

// Async wrapper — возвращает [data, error]
async function tryCatch<T>(
  promise: Promise<T>
): Promise<[T | null, Error | null]> {
  try {
    const data = await promise
    return [data, null]
  } catch (err) {
    return [null, err as Error]
  }
}

const [data, error] = await tryCatch(fetch('/api/users'))
if (error) {
  console.error(error.message)
} else {
  console.log(data)  // TypeScript знает что это Response
}

Примеры

Generic-стиль структуры данных: Stack, Queue, Pair с runtime type tracking

// В TypeScript это было бы Stack<T>, Queue<T>, Pair<A,B>
// В JS эмулируем через runtime проверку типов

class Stack {
  #items = []
  #typeName = null  // запоминаем тип первого элемента

  push(item) {
    const itemType = Array.isArray(item) ? 'array' : typeof item
    if (this.#typeName === null) {
      this.#typeName = itemType  // определяем тип по первому элементу
    } else if (itemType !== this.#typeName) {
      throw new TypeError(
        `Stack<${this.#typeName}>: нельзя добавить ${itemType}`
      )
    }
    this.#items.push(item)
    return this
  }

  pop() {
    if (this.#items.length === 0) return undefined
    return this.#items.pop()
  }

  peek() {
    return this.#items[this.#items.length - 1]
  }

  get size() { return this.#items.length }
  get isEmpty() { return this.#items.length === 0 }
  get type() { return this.#typeName ?? 'unknown' }

  toString() {
    return `Stack<${this.type}>([${this.#items.join(', ')}])`
  }
}

class Queue {
  #items = []
  #typeName = null

  enqueue(item) {
    const itemType = Array.isArray(item) ? 'array' : typeof item
    if (this.#typeName === null) {
      this.#typeName = itemType
    } else if (itemType !== this.#typeName) {
      throw new TypeError(
        `Queue<${this.#typeName}>: нельзя добавить ${itemType}`
      )
    }
    this.#items.push(item)
    return this
  }

  dequeue() {
    return this.#items.shift()
  }

  front() {
    return this.#items[0]
  }

  get size() { return this.#items.length }
  get isEmpty() { return this.#items.length === 0 }
  get type() { return this.#typeName ?? 'unknown' }
}

// Pair<A, B> — пара из двух возможно разных типов
class Pair {
  constructor(first, second) {
    this.first = first
    this.second = second
    this.firstType = typeof first
    this.secondType = typeof second
  }

  swap() {
    return new Pair(this.second, this.first)
  }

  map(fnFirst, fnSecond) {
    return new Pair(fnFirst(this.first), fnSecond(this.second))
  }

  toString() {
    return `Pair<${this.firstType}, ${this.secondType}>(${this.first}, ${this.second})`
  }
}

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

console.log('=== Stack<number> ===')
const numStack = new Stack()
numStack.push(10).push(20).push(30)
console.log(numStack.toString())          // Stack<number>([10, 20, 30])
console.log(`peek: ${numStack.peek()}`)   // 30
console.log(`pop:  ${numStack.pop()}`)    // 30
console.log(`size: ${numStack.size}`)     // 2

try {
  numStack.push('hello')  // TypeError: Stack<number> нельзя добавить string
} catch (e) {
  console.log(`Ошибка типа: ${e.message}`)
}

console.log('\n=== Queue<string> ===')
const strQueue = new Queue()
strQueue.enqueue('first').enqueue('second').enqueue('third')
console.log(`front: ${strQueue.front()}`)     // 'first'
console.log(`dequeue: ${strQueue.dequeue()}`) // 'first'
console.log(`size: ${strQueue.size}`)         // 2
console.log(`type: ${strQueue.type}`)         // 'string'

console.log('\n=== Pair<string, number> ===')
const p = new Pair('hello', 42)
console.log(p.toString())  // Pair<string, number>(hello, 42)

const swapped = p.swap()
console.log(swapped.toString())  // Pair<number, string>(42, hello)

const mapped = p.map(s => s.toUpperCase(), n => n * 2)
console.log(mapped.toString())  // Pair<string, number>(HELLO, 84)

console.log('\n=== Практичный пример: очередь задач ===')
const taskQueue = new Queue()
taskQueue
  .enqueue('fetch-users')
  .enqueue('process-data')
  .enqueue('send-report')

while (!taskQueue.isEmpty) {
  const task = taskQueue.dequeue()
  console.log(`Выполняю задачу: ${task}`)
}