Типы и интерфейсы
Я работаю руководителем направления клиентской разработки и временами нанимаю фронтендеров. Последний найм-марафон продлился чуть более месяца, и я поговорил примерно с десятком кандидатов.
Во время этих всех собеседований я спрашивал у кандидатов в том числе и такой вопрос: — Когда ты используешь типы, а когда интерфейсы?
Вроде бы простой выбор, с которым каждый день десятки раз сталкивается разработчик. Но ответы заставили меня задуматься и пересмотреть свое отношение к этому вопросу.
Я обнаружил, что по ответу на этот вопрос можно с высокой вероятностью понять опыт кандидата. Если кандидат говорит, что для типизации пропсов в реактике он использует интерфейсы, то в большинстве случаев он начал использовать тайпскрипт ещё, когда реакт был на классах. Фронтендеры же новой школы поголовно всё типизируют типами. И с этим, в принципе, нет никаких проблем, кроме одной.
Ни те, ни другие не могут однозначно сказать на основе чего делают выбор. Поголовно это чисто интуитивное решение: из серии «прокатит — оставляем, не прокатило — переделываем». И их можно понять, эта схема работает довольно хорошо, зачем что-то менять. Но что я хочу услышать от кандидата?
Я не требую изложить мне всю теорию. Мне достаточно услышать хотя бы базовые три пункта:
— во многих случаях они взаимозаменяемы
— интерфейсы в библиотеках удобны тем, что их можно дополнить снаружи
— через type можно описать не только объект.
Этого более чем достаточно, чтобы ответ меня устроил. Но я задумался, а знаю ли я сам ответ на этот вопрос на 100%?
Я полез в документацию и пропал. Мне кажется я раскопал столько всего странного и необычного, с чем не сталкивается типичный разработчик. И всё это только вокруг вопроса про базовые понятия типов и интерфейсов. Я сел записывать видео, начинал несколько раз, но каждый раз я понимал, что видео будет не полным, потому что вот есть еще такое и такое. А еще вот тут отличается. А еще и такую штуку можно делать. Офигеть!
В основном будем разбирать простой объект: тут type и interface почти одно и то же. А где начинаются отличия — посмотрим на примерах. В TypeScript 7.0 всё это по-прежнему работает.
Содержание
- Начинаем погружение
- Немного терминологии
- Расширение
- Классы
- Дженерики
- Расширение интерфейсов — Declaration Merging
- Возможности псевдонимов
- Итоги
Начинаем погружение!
Путь предстоит долгий, поэтому нужно хорошенько подготовиться и вспомнить основы. Давайте на берегу вспомним как определяются интерфейсы и типы, в чем отличие и как все это дело связано.
Интерфейсы описывают объекты. Выглядит это следующим образом:
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 смотрит на поля объекта, а не на то, через type или interface мы их описали. Это называется структурной типизацией. Проще говоря: «выглядит как утка, крякает как утка, значит это утка».
Немного терминологии
Прежде чем углубляться дальше, синканемся по терминологии, чтобы дальнейший разговор был на одном языке. В русскоязычном интернете особо не встречал, а вот в англоязычном наткнулся на следующее разделение: псевдонимы, интерфейсы и классы. Это очень помогает, потому что раньше у меня мозг взрывался когда хотел написать «тип задаем с помощью типа, а тут тип задаем с помощью интерфейса» — получалась какая-то тавтология.
Короче, когда я дальше буду писать псевдоним, надо понимать, что речь идет именно о type.
Теперь всё встало на свои места, и можно сделать базовые выводы:
- Классы, интерфейсы и псевдонимы (они же алиасы) — всё это способы задать тип.
- Через класс получим и тип, и все ништяки обычного JavaScript-класса.
- Интерфейс описывает объект. Его можно расширять и дополнять.
- Псевдоним — это имя для типа, который мы записали после
=. Не обязательно объекта.
Раньше подсказки в редакторе часто раскрывали псевдоним целиком, а интерфейс показывали по имени. Сейчас с этим лучше, а с TypeScript 5.9 подсказки можно раскрывать.
Запомнили? Отлично, идём дальше.
Расширение
Цель этого блока — получить в результате описание объекта с тремя полями: first, second и third.
Давайте возьмем уже знакомые нам тип AType и интерфейс AInt и на их основе создадим новые.
Интерфейсы расширяются с помощью ключевого слова extends:
interface BInt extends AInt {
third: string;
}
С типами такую запись мы использовать не можем. Поэтому нам на помощь приходит инструмент «пересечение», который обозначается символом &. В результате мы получаем запись, которая выглядит иначе, но так же решает нашу задачу.
type BType = AType & {
third: string;
}
На этом можно было бы и закончить. Но не возник ли у вас вопрос, а можем ли мы применить ключевое слово extends к типу или & к интерфейсу? Давайте попробуем!
interface BInt extends AType {
third: string;
}
Внезапно! Эта запись работает! Интерфейсу без разницы, через что описан наш объект — interface или type. Главное, чтобы было понятно, какие у него поля.
Но если расширять 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 с & справляется без проблем
А & справляется: добавляет extra к обоим вариантам. Но это не совсем наследование — результат должен подходить сразу под обе части. И тут есть подвох.
Важное отличие: конфликт полей
Но у гибкости & есть обратная сторона. Если при расширении возникает конфликт типов одного и того же поля, 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
// @ts-expect-error: number нельзя присвоить полю типа never
const b: B2 = { x: 42 };
С extends ошибку видно сразу, а с & можно случайно собрать тип, которому ничего не подходит. Заодно 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 ничего не копирует из AClass. Он только проверяет, что нужные поля есть. Конструктор и статические поля не проверяются, поэтому first и second мы прописали сами.
А вот с private и protected такой фокус не пройдёт: просто повторить поля уже недостаточно, понадобится наследование.
И не путаем: extends — наследуемся от класса, implements — проверяем, что всё нужное есть. Вот так можно описать класс по нашему AInt и добавить третье поле:
class BClass implements AInt {
public first: boolean;
public second: number;
public third: string;
constructor() {
this.first = true;
this.second = 1;
this.third = 'Hello';
}
}
С нашими AType и AClass это тоже работает. Можете попробовать сами.
Дженерики
Что такое дженерик? По факту — это инструмент языка 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 сольёт их в один. Но поменять name: string на name: number не получится — будет ошибка.
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:
// Делаем файл модулем, если в нём ещё нет import/export
export {};
declare global {
interface Window {
myPlugin: () => void;
}
}
// И теперь TypeScript знает про window.myPlugin
window.myPlugin = () => console.log('работает!');
Если в файле есть import или export, оборачиваем интерфейс в declare global. Иначе до настоящего Window наше дополнение не доберётся.
Похожим способом можно дополнять интерфейсы библиотек — через declare module. Поэтому, если хочешь дать пользователям библиотеки такую возможность, экспортируй интерфейс.
Возможности псевдонимов
Мы уже разобрались, в чём интерфейс выигрывает у псевдонима. Теперь посмотрим на обратную сторону. У псевдонимов есть целый ряд вещей, которые интерфейс попросту не умеет.
Union types — объединение типов
Самая частая штука, с которой интерфейс не справится:
type Status = 'active' | 'inactive' | 'pending';
type ID = string | number;
Tuple — кортежи
Массив с фиксированным количеством элементов и конкретными типами для каждой позиции:
type Point = [number, number];
type Entry = [string, number]; // например, ['age', 30]
С интерфейсом такую короткую запись не сделать.
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>; // 'нет'
Всё это можно использовать в полях интерфейса. Но для самих таких типов нужен type.
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 |
| Конфликт типов одного поля | Можно получить never | Ошибка сразу |
| Дополнить объявление снаружи | ❌ | ✅ |
implements в классе | ✅ для нашего объекта | ✅ |
| Дженерики | ✅ | ✅ |
| Union, примитивы, кортежи | ✅ | ❌ |
| Mapped и conditional types | ✅ | ❌ |
Для простого объекта подходят оба варианта. Посмотри, как принято в проекте, и делай так же.
Используй interface, когда объект нужно дополнять снаружи или удобнее собирать через extends.
Используй type, когда нужен не просто объект: union, кортеж или другие штуки из раздела выше.
А пропсы в реактике? Если это обычный объект — оба варианта годятся. Сам по себе React ничего тут не меняет.
Подписывайся на обновления в блоге в Telegram