← Курс/Interface vs Type: что выбрать и когда#162 из 257+20 XP

Interface vs Type: что выбрать и когда

Базовое сходство

На первый взгляд interface и type взаимозаменяемы:

// Оба описывают форму объекта
interface UserInterface {
  id: number
  name: string
}

type UserType = {
  id: number
  name: string
}

// Оба работают одинаково:
const u1: UserInterface = { id: 1, name: 'Алексей' }
const u2: UserType = { id: 1, name: 'Алексей' }

Ключевое отличие 1: Declaration Merging

Интерфейсы поддерживают слияние объявлений — несколько деклараций с одним именем объединяются автоматически:

interface Window {
  myPlugin: () => void
}
// TypeScript добавит myPlugin к встроенному интерфейсу Window

interface Config {
  host: string
}
interface Config {
  port: number  // добавляется к первому объявлению
}
// Итог: Config = { host: string; port: number }

// С type это невозможно:
type Config2 = { host: string }
// type Config2 = { port: number }  // Ошибка: Duplicate identifier 'Config2'

Declaration merging полезен при расширении сторонних библиотек.

Ключевое отличие 2: Вычисляемые типы и unions

Type aliases могут описывать то, что interface не может:

// Union type — только через type:
type StringOrNumber = string | number
type Status = 'active' | 'inactive' | 'pending'

// Пересечения через type:
type AdminUser = User & { permissions: string[] }

// Кортежи:
type Pair = [string, number]

// Primitive alias:
type UserId = string  // interface не может алиасить примитив

// Conditional type:
type NonNullable<T> = T extends null | undefined ? never : T

Ключевое отличие 3: extends vs Intersection

Оба механизма расширяют тип, но с разным поведением при конфликте:

interface A { x: string }
interface B extends A { x: string; y: number }  // OK — совместимый тип

type C = { x: string }
type D = C & { x: number }  // Допускается, но x будет never (string & number)

// extends даёт ошибку при несовместимых типах — это лучше!
interface E { x: string }
// interface F extends E { x: number }  // Ошибка TS — несовместимый тип обнаружен сразу

Ключевое отличие 4: implements

Класс может реализовать interface или type-alias объекта:

interface Serializable {
  serialize(): string
}

type HasId = { id: number }

class Document implements Serializable, HasId {
  id = 0
  serialize() { return JSON.stringify(this) }
}
// Оба работают с implements

Когда что выбирать

Используй `interface`:

  • Для описания формы объектов и классов
  • Когда нужно расширение через declaration merging (например, module augmentation)
  • Для публичного API библиотеки — более чёткие сообщения об ошибках
  • Используй `type`:

  • Для union и intersection типов
  • Для кортежей, примитивных алиасов
  • Для conditional и mapped типов
  • Когда нужны вычисляемые типы
  • // Рекомендуемая практика:
    interface User {        // объект — interface
      id: UserId
      name: string
      role: UserRole
    }
    
    type UserId   = string           // примитив — type
    type UserRole = 'admin' | 'user' // union — type
    
    type UserWithMeta = User & {     // intersection — type
      createdAt: Date
      updatedAt: Date
    }

    Примеры

    Паттерн расширения: базовый объект + дополнительные поля через Object.assign и spread — аналог interface extends и type intersection

    // В TypeScript: interface extends и type intersection
    // В JS — показываем runtime-аналоги этих паттернов
    
    // --- Аналог interface extends ---
    
    function createBase(id, name) {
      return { id, name }
    }
    
    function createUser(id, name, email, role = 'user') {
      // extends Base: добавляем поля поверх базы
      return Object.assign(createBase(id, name), { email, role })
    }
    
    function createAdmin(id, name, email, permissions) {
      // extends User: добавляем поля поверх User
      return Object.assign(createUser(id, name, email, 'admin'), { permissions })
    }
    
    // --- Аналог type intersection (A & B) ---
    
    function withTimestamps(obj) {
      return Object.assign({}, obj, {
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      })
    }
    
    function withSoftDelete(obj) {
      return Object.assign({}, obj, {
        deletedAt: null,
        isDeleted: false,
      })
    }
    
    // Intersection через spread: User & Timestamped & SoftDeletable
    function createFullRecord(id, name, email) {
      const base = createUser(id, name, email)
      return withSoftDelete(withTimestamps(base))
    }
    
    // --- Declaration merging через Object.assign (расширение контракта) ---
    
    // Базовый "интерфейс" для плагинов
    const pluginRegistry = {}
    
    function registerPlugin(name, plugin) {
      pluginRegistry[name] = plugin
    }
    
    // Каждый модуль "расширяет" registry — аналог declaration merging
    registerPlugin('logger', {
      log: (msg) => console.log('[LOG]', msg),
      warn: (msg) => console.log('[WARN]', msg),
    })
    
    registerPlugin('validator', {
      required: (val) => val != null && val !== '',
      email:    (val) => /S+@S+.S+/.test(val),
    })
    
    // --- Демонстрация ---
    
    console.log('=== Иерархия extends ===')
    const user = createUser(1, 'Алексей', 'alex@mail.ru')
    console.log('User:', user)
    
    const admin = createAdmin(2, 'Ольга', 'olga@mail.ru', ['read', 'write', 'delete'])
    console.log('Admin:', admin)
    
    // Полиморфизм — оба имеют id и name:
    const entities = [user, admin]
    entities.forEach(e => console.log(`  id=${e.id}, name=${e.name}, role=${e.role}`))
    
    console.log('\n=== Intersection (& пересечение) ===')
    const record = createFullRecord(3, 'Иван', 'ivan@mail.ru')
    console.log('Full record:', record)
    console.log('Все поля:', Object.keys(record).join(', '))
    
    console.log('\n=== Declaration merging (плагины) ===')
    console.log('Плагины:', Object.keys(pluginRegistry).join(', '))
    pluginRegistry.logger.log('Приложение запущено')
    pluginRegistry.logger.warn('Память заканчивается')
    console.log('Email валидный?', pluginRegistry.validator.email('test@example.com'))
    console.log('Email пустой?', pluginRegistry.validator.required(''))
    
    console.log('\n=== Конфликт типов при intersection ===')
    // В TypeScript: type A = { x: string } & { x: number } → x: never
    // В JS нет системы типов, но можно показать перезапись:
    const a = { x: 'строка', y: 1 }
    const b = { x: 42,       z: true }
    const merged = Object.assign({}, a, b)
    console.log('После merge, x =', merged.x, typeof merged.x)
    // В TypeScript это было бы ошибкой компиляции (never)