Типы и интерфейсы


Я работаю руководителем направления клиентской разработки и временами нанимаю фронтендеров. Последний найм-марафон продлился чуть более месяца, и я поговорил примерно с десятком кандидатов.

Во время этих всех собеседований я спрашивал у кандидатов в том числе и такой вопрос: — Когда ты используешь типы, а когда интерфейсы?

Вроде бы простой выбор, с которым каждый день десятки раз сталкивается разработчик. Но ответы заставили меня задуматься и пересмотреть свое отношение к этому вопросу.

Я обнаружил, что по ответу на этот вопрос можно с высокой вероятностью понять опыт кандидата. Если кандидат говорит, что для типизации пропсов в реактике он использует интерфейсы, то в большинстве случаев он начал использовать тайпскрипт ещё, когда реакт был на классах. Фронтендеры же новой школы поголовно всё типизируют типами. И с этим, в принципе, нет никаких проблем, кроме одной.

Ни те, ни другие не могут однозначно сказать на основе чего делают выбор. Поголовно это чисто интуитивное решение: из серии «прокатит — оставляем, не прокатило — переделываем». И их можно понять, эта схема работает довольно хорошо, зачем что-то менять. Но что я хочу услышать от кандидата?

Я не требую изложить мне всю теорию. Мне достаточно услышать хотя бы базовые три пункта: — во многих случаях они взаимозаменяемы — в библиотеках лучше экспортировать интерфейсы, так как их можно расширить — в тип можно записать любой существующий тип и не только. Этого более чем достаточно, чтобы ответ меня устроил. Но я задумался, а знаю ли я сам ответ на этот вопрос на 100%?

Я полез в документацию и пропал. Мне кажется я раскопал столько всего странного и необычного, с чем не сталкивается типичный разработчик. И всё это только вокруг вопроса про базовые понятия типов и интерфейсов. Я сел записывать видео, начинал несколько раз, но каждый раз я понимал, что видео будет не полным, потому что вот есть еще такое и такое. А еще вот тут отличается. А еще и такую штуку можно делать. Офигеть!

Содержание

  1. Начинаем погружение
  2. Немного терминологии
  3. Расширение
  4. Классы
  5. Дженерики
  6. Расширение интерфейсов — Declaration Merging
  7. Возможности псевдонимов
  8. Итоги

Начинаем погружение!

Путь предстоит долгий, поэтому нужно хорошенько подготовиться и вспомнить основы. Давайте на берегу вспомним как определяются интерфейсы и типы, в чем отличие и как все это дело связано.

Интерфейсы описывают объекты. Выглядит это следующим образом:

interface AInt {
  first: boolean;
  second: number;
}

Здесь мы определили интерфейс с именем AInt и двумя полями: first и second. Надеюсь на этом этапе все понятно.

Такую же структуру объекта мы можем описать и с помощью типа:

type AType = {
  first: boolean;
  second: number;
}

Обратите внимание, здесь мы использовали ключевое слово type, и после названия добавили знак =. Это первое отличие в синтаксисе. Но в остальном отличий нет.

Теперь мы можем создать объекты, которые будут соответствовать нашим AInt и AType:

const aInt: AInt = {
  first: true,
  second: 10,
}

const aType: AType = {
  first: false,
  second: 42,
}

Как видите, разницы никакой. Более того, мы можем сделать функцию, которая принимает аргументы с типом AInt и подсунуть туда aType, код отработает без ошибок:

function useInterface(param: AInt): void {
  console.log(param);
}

useInterface(aInt); // ошибки нет
useInterface(aType); // и здесь ошибки нет тоже

TypeScript использует механизм структурной типизации — совместимость типов определяется на основе их структуры. Проще говоря: «выглядит как утка, крякает как утка, значит это утка», прямо как в JS. А вот в языках типа C# или Java такое не прокатит, потому что в них используется более строгая типизация — номинативная.

Немного терминологии

Прежде чем углубляться дальше, синканемся по терминологии, чтобы дальнейший разговор был на одном языке. В русскоязычном интернете особо не встречал, а вот в англоязычном наткнулся на следующее разделение: псевдонимы, интерфейсы и классы. Это очень помогает, потому что раньше у меня мозг взрывался когда хотел написать «тип задаем с помощью типа, а тут тип задаем с помощью интерфейса» — получалась какая-то тавтология.

Теперь всё встало на свои места, и можно сделать базовые выводы:

  1. Классы, интерфейсы и псевдонимы (они же алиасы) — это всё типы.
  2. Тип можно описать с помощью класса. В этом случае получим все ништяки интерфейса и JavaScript-класса.
  3. Можно это сделать с помощью интерфейса. Тогда у типа будет своё имя и появится возможность расширяться.
  4. А можно создать анонимный тип и сохранить его в переменную псевдонима. Исторически в тултипах IDE типы, созданные через type, отображались как развёрнутая структура, а interface — по имени. Начиная с TypeScript ~4.2 алиасы стали лучше сохранять свои имена, но различия в отображении всё ещё встречаются. В рантайме же (дебаггер браузера) разницы нет — все типы стираются при компиляции.

Запомнили? Отлично, идём дальше.

Расширение

Цель этого блока — получить в результате описание объекта с тремя полями: first, second и third.

Давайте возьмем уже знакомые нам тип AType и интерфейс AInt и на их основе создадим новые.

Интерфейсы расширяются с помощью ключевого слова extends:

interface BInt extends AInt {
  third: string;
}

С типами такую запись мы использовать не можем. Поэтому нам на помощь приходит инструмент «пересечение», который обозначается символом &. В результате мы получаем запись, которая выглядит иначе, но так же решает нашу задачу.

type BType = AType & {
  third: string;
}

На этом можно было бы и закончить. Но не возник ли у вас вопрос, а можем ли мы применить ключевое слово extends к типу или & к интерфейсу? Давайте попробуем!

interface BInt extends AType {
  third: string;
}

Внезапно! Эта запись работает! Получается, что интерфейсам в целом без разницы что расширять. Они одинаково хорошо расширяют как другой интерфейс, так и тип.

Но есть одно исключение. Если тип содержит union — extends сломается:

type AdminOrUser = { role: 'admin' } | { role: 'user' };

interface Ext extends AdminOrUser { // ❌ Ошибка: An interface can only extend an object type
  extra: string;
}

type TExt = AdminOrUser & { extra: string }; // ✅ type с & справляется без проблем

Именно поэтому & для расширения типов гибче в общем случае — он не накладывает ограничений на форму того, что расширяет.

Важное отличие: конфликт полей

Но у гибкости & есть обратная сторона. Если при расширении возникает конфликт типов одного и того же поля, extends и & ведут себя по-разному:

interface A { x: number }
interface B extends A { x: string } // ❌ Ошибка — TypeScript сразу покажет конфликт

А вот & молча «пересечёт» типы, и поле станет never:

type A2 = { x: number }
type B2 = A2 & { x: string } // ✅ Компилируется, но x: number & string → never

const b: B2 = { x: ??? } // невозможно задать значение для x

extends защищает от таких багов на этапе объявления, тогда как & даёт гибкость, но требует внимательности.

Что же с инструментом пересечения?

type BType = AInt & {
  third: string;
}

Да, он тоже прекрасно работает как с интерфейсами, так и типами.

Более того, оказывается, мы можем использовать в качестве базы еще одну сущность — класс.

Для примера создадим класс с двумя полями: first и second.

class AClass {
  public first: boolean;
  public second: number;

  constructor(x: boolean, y: number) {
    this.first = x;
    this.second = y;
  }
}

А теперь на его основе мы можем реализовать целевые тип и интерфейс:

interface BInt extends AClass {
  third: string;
}

type BType = AClass & {
  third: string;
}

Трудно поверить, но все эти инструменты и варианты записи дают нам идентичный результат.

Классы

Раз уж мы заговорили про классы, то давайте проясним и связанные с ними моменты.

Исторически так сложилось, что классы имплементируют, а простыми словами — реализуют, интерфейсы. Интерфейс является описанием, которому экземпляры класса должны соответствовать.

Сразу приведу пример: У нас есть уже наш давний знакомый интерфейс AInt, и его может имплементировать класс CClass. Выглядеть будет вот так:

// Описываем интерфейс
interface AInt {
  first: boolean;
  second: number;
}

// Имплементируем
class CClass implements AInt {
  first = true;
  second = 20;
}

// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true

А теперь интересно, сможем ли мы создать объект с другими значениями, ведь мы зафиксировали конкретные значения first и second в конструкторе. Пробуем:

const a: CClass = {
  first: false,
  second: 15,
}

const b: AInt = {
  first: false,
  second: 15,
}

Да, все получилось! Класс в этом случае ведет себя так же как и интерфейс. Результаты идентичны.

А как поживает наш type? Думаю, вы уже догадались. Благодаря структурной типизации TypeScript позволяет использовать implements не только с interface, но и с type.

// Описываем тип
type AType = {
  first: boolean;
  second: number;
}

// Имплементируем
class CClass implements AType {
  first = true;
  second = 20;
}

// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true

Более того, чтобы показать, что TypeScript не видит разницы не только в типах и интерфейсах, но и в классах, я воспользуюсь в качестве основы уже знакомым нам классом AClass:

// Описываем класс
class AClass {
  public first: boolean;
  public second: number;

  constructor(x: boolean, y: number) {
    this.first = x;
    this.second = y;
  }
}

// Имплементируем
class CClass implements AClass {
  first = true;
  second = 20;
}

// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true

Обратите внимание: когда класс имплементирует другой класс, implements работает только с публичным контрактом — конструктор, приватные (private) и защищённые (protected) поля исходного класса игнорируются. То есть CClass в примере выше не наследует конструктор AClass, а лишь обязуется иметь поля first и second с совместимыми типами.

И здесь еще хочется добавить про расширение. Классы тоже умеют расширять типы, интерфейсы и другие классы. И да, у них тоже особенный синтаксис. Вот так можно добавить третье поле к AInt с помощью класса:

class BClass implements AInt {
  public first: boolean;
  public second: number;
  public third: string;

  constructor() {
    this.first = true;
    this.second = 1;
    this.third = 'Hello';
  }
}

Расширять, как вы поняли, можно так же и type, и class. Но это я позволю вам попробовать сделать самостоятельно.

Дженерики

Что такое дженерик? По факту — это инструмент языка TypeScript для описания структур данных, в которых тип каких-либо параметров структуры может задаваться извне. Дженерики в целом достойны отдельной статьи, здесь же разберем особенности в рамках сравнения типов и интерфейсов.

Возьмем привычные нам AInt и AType, назовем их DInt и DType соответственно, и сделаем так, чтобы тип параметра second можно было задавать.

// Определяем интерфейс
interface DInt<ExternalType> {
  first: boolean;
  second: ExternalType;
}

// Определяем тип
type DType<ExternalType> = {
  first: boolean;
  second: ExternalType;
}

Здесь мы создали переменную типа ExternalType. При создании константы с типом DInt или DType мы должны указывать тип, которому будет соответствовать параметр second.

Если его не передать — будет ошибка. Если фактическое значение не будет совпадать с указанным, то тоже будет ошибка. Посмотрите:

// Будет ошибка: Generic type 'DInt<ExternalType>' requires 1 type argument(s)
const dIntError: DInt = {
  first: true,
  second: 15,
}

// Так правильно
const dInt: DInt<number> = {
  first: true,
  second: 15,
} 

// Так будет ошибка: Type 'string' is not assignable to type 'number'
const dTypeError: DType<number> = {
  first: true,
  second: 'bla bla',
}

// Так правильно
const dType: DType<string> = {
  first: true,
  second: 'bla bla',
}

На примерах видно, что определение и использование различается только на уровне определения ровно настолько, насколько в принципе различаются type и interface.

Расширение интерфейсов — Declaration Merging

Вот мы и добрались до одной из самых важных особенностей интерфейсов. Называется она Declaration Merging — слияние объявлений.

Суть простая: если вы объявите два интерфейса с одинаковым именем в одном скоупе, TypeScript не ругнется, а тихо сольёт их в один.

interface User {
  name: string;
}

interface User {
  age: number;
}

// TypeScript видит это как:
// interface User {
//   name: string;
//   age: number;
// }

const user: User = {
  name: 'Иван',
  age: 30,
}

С псевдонимами такой трюк не пройдёт:

type User = { name: string; }
type User = { age: number; } // ❌ Ошибка: Duplicate identifier 'User'

Зачем это нужно? Это мощный инструмент для расширения сторонних библиотек без изменения их исходников. Например, мы хотим добавить поле в глобальный объект Window:

// В нашем коде:
interface Window {
  myPlugin: () => void;
}

// И теперь TypeScript знает про window.myPlugin
window.myPlugin = () => console.log('работает!');

Именно поэтому в публичных библиотеках принято экспортировать интерфейсы, а не псевдонимы — пользователь библиотеки сможет их расширить под свои нужды, не трогая чужой код.

Возможности псевдонимов

Мы уже разобрались, в чём интерфейс выигрывает у псевдонима. Теперь посмотрим на обратную сторону. У псевдонимов есть целый ряд вещей, которые интерфейс попросту не умеет.

Union types — объединение типов

Самая частая штука, с которой интерфейс не справится:

type Status = 'active' | 'inactive' | 'pending';
type ID = string | number;

Tuple — кортежи

Массив с фиксированным количеством элементов и конкретными типами для каждой позиции:

type Point = [number, number];
type Entry = [string, number]; // например, ['age', 30]

Технически можно определить кортеж через interface extends Array<T>, но нативного синтаксиса для этого нет, и на практике это неудобно — используйте type.

Mapped types — отображаемые типы

Позволяют создавать новые типы на основе существующих, проходясь по их ключам:

// Используем префикс My, чтобы не затенять встроенные Readonly<T> и Partial<T>
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
}

type MyOptional<T> = {
  [K in keyof T]?: T[K];
}

Conditional types — условные типы

Тип, который зависит от условия — почти как тернарный оператор, но на уровне типов:

type IsString<T> = T extends string ? 'да' : 'нет';

type A = IsString<string>; // 'да'
type B = IsString<number>; // 'нет'

Все эти конструкции — только для псевдонимов. Попробуйте написать то же самое через interface — TypeScript немедленно возразит.

Index signature — это не то же самое, что Mapped type

Часто путают два похожих синтаксиса. У интерфейсов есть индексная подпись — способ сказать «любой ключ такого типа»:

interface Dictionary {
  [key: string]: number; // любой строковый ключ → число
}

Это статическая маска: мы не знаем конкретных ключей и не вычисляем ничего. А вот mapped type — настоящая динамика: новый тип строится на основе ключей другого:

type User = { id: number; name: string };

// Строим новый тип, проходя по ключам User
type Flags<T> = { [K in keyof T]: boolean };
type UserFlags = Flags<User>; // { id: boolean; name: boolean }

Если попробовать mapped type через interface:

interface ReadonlyUser {
  [K in keyof User]: User[K]; // ❌ A mapped type may not declare properties or methods
}

Таким образом, хотя синтаксис внешне похож, индексная подпись и mapped type — это принципиально разные вещи.

Итоги

Давайте подобьём всё, что разобрали, в одну таблицу:

Возможностьtype (псевдоним)interface
Описание объекта
Расширение через extends / &
implements в классе
Дженерики
Declaration Merging
Extends union-типов
Union / Literal types
Tuple
Mapped types
Conditional types

Из таблицы вытекает довольно простое правило:

Используй interface, когда описываешь контракт объекта, который должен быть расширяемым снаружи. Это прежде всего публичные API библиотек, экспортируемые типы, контракты для классов.

Используй type для всего остального: union-типы, кортежи, вычисляемые типы, пропсы React-компонентов, утилитарные типы. В большинстве прикладного кода именно псевдоним окажется более гибким инструментом.

Если не уверен — посмотри, нужна ли тебе возможность расширять тип снаружи. Нужна — interface. Нет — type.


Подписывайся на обновления в блоге в Telegram