Хотите заказать веб-сайт? Связаться с нами

Интерфейсы в TypeScript: расширяемые типы

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

Интерфейсы в TypeScript: расширяемые типы

Что такое интерфейс в TypeScript

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

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

Основные преимущества использования интерфейсов

  1. Явное описание структуры данных, понятное всей команде
  2. Автодополнение и подсказки в редакторе кода
  3. Раннее обнаружение ошибок ещё до запуска приложения
  4. Возможность расширять и переиспользовать типы без дублирования кода

Как объявить интерфейс

Интерфейс объявляется с помощью ключевого слова interface, за которым следует имя и тело с перечислением свойств. Каждое свойство описывается именем и типом, разделёнными двоеточием.

interface User {
  id: number;
  name: string;
  email: string;
}
 
const user: User = {
  id: 1,
  name: 'Ирина',
  email: 'irina@example.com',
};

Если объект, присвоенный переменной типа User, не будет содержать хотя бы одного обязательного свойства или тип свойства не совпадёт, компилятор TypeScript сразу сообщит об ошибке ещё до запуска кода.

Необязательные и readonly свойства

Не все свойства объекта обязательно должны присутствовать всегда. Чтобы сделать свойство необязательным, после его имени ставится знак вопроса. Для свойств, которые нельзя изменять после создания объекта, используется модификатор readonly.

interface Product {
  readonly id: number;
  title: string;
  discount?: number;
}
 
const product: Product = {
  id: 101,
  title: 'Клавиатура',
};
 
// product.id = 102; 
// ошибка: свойство доступно только для чтения

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

Методы внутри интерфейса

Интерфейс может описывать не только свойства-данные, но и методы, которые должен реализовать объект. Сигнатура метода в интерфейсе выглядит так же, как объявление функции, но без тела.

interface Logger {
  log(message: string): void;
  warn(message: string, code?: number): void;
}
 
const consoleLogger: Logger = {
  log(message) {
    console.log(message);
  },
  warn(message, code) {
    console.warn(code ? `[${code}] ${message}` : message);
  },
};

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

Интерфейс как тип функции

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

interface Comparator {
  (a: number, b: number): number;
}
 
const byAscending: Comparator = (a, b) => a - b;
 
const numbers = [5, 1, 4, 2, 3];
numbers.sort(byAscending);

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

Расширение интерфейсов через extends

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

interface Animal {
  name: string;
  sound(): string;
}
 
interface Dog extends Animal {
  breed: string;
}
 
const rex: Dog = {
  name: 'Рекс',
  breed: 'Лабрадор',
  sound() {
    return 'Гав!';
  },
};

Интерфейс Dog унаследовал свойство name и метод sound от Animal, добавив собственное свойство breed. Такой подход помогает выстраивать иерархию типов и избегать дублирования кода: базовые свойства описываются один раз, а специфичные — в дочерних интерфейсах.

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

Расширение нескольких интерфейсов сразу

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

interface Timestamped {
  createdAt: Date;
}
 
interface Identifiable {
  id: string;
}
 
interface Order extends Timestamped, Identifiable {
  total: number;
}
 
const order: Order = {
  id: 'ord-001',
  createdAt: new Date(),
  total: 2500,
};

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

Интерфейсы и классы через implements

Интерфейсы можно использовать не только для переменных и объектов, но и для классов. Ключевое слово implements указывает, что класс обязан реализовать все свойства и методы, описанные в интерфейсе.

interface Shape {
  area(): number;
}
 
class Circle implements Shape {
  constructor(private radius: number) {}
 
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}
 
const circle = new Circle(5);
console.log(circle.area());

Если в классе Circle забыть реализовать метод area или изменить его сигнатуру, компилятор сразу укажет на несоответствие интерфейсу Shape. Это особенно полезно в больших приложениях, где один и тот же интерфейс может реализовываться сразу несколькими классами — например, разными видами фигур или разными стратегиями оплаты.

Интерфейсы и псевдонимы типов

Начинающие часто путают интерфейсы (interface) и псевдонимы типов (type), потому что во многих случаях они взаимозаменяемы. Тем не менее между ними есть важные различия.

Интерфейс Псевдоним типа
Описывает только форму объектов и классов Может описывать любые типы: объекты, union, примитивы
Поддерживает declaration merging — повторное объявление дополняет тип Повторное объявление с тем же именем вызывает ошибку
Расширяется через ключевое слово extends Комбинируется через пересечение типов с помощью &

Например, один и тот же интерфейс можно объявить в коде дважды — TypeScript объединит оба объявления в один тип с полным набором свойств. Псевдоним типа с тем же именем при повторном объявлении, наоборот, вызовет ошибку компиляции.

interface Config {
  darkMode: boolean;
}
 
interface Config {
  language: string;
}
 
const settings: Config = {
  darkMode: true,
  language: 'ru',
};

На практике многие команды придерживаются простого правила:

  1. Использовать interface для описания объектов и классов, особенно если форма может расширяться в будущем
  2. Использовать type для union-типов, кортежей и любых комбинаций, которые не сводятся к простому объекту

Если сомневаетесь, какой вариант выбрать, начните с interface и переходите на type только тогда, когда потребуются возможности, которых у интерфейсов нет — например, объединение через | или работа с примитивными типами.

Композиция и переиспользование

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

interface WithId {
  id: string;
}
 
interface WithName {
  name: string;
}
 
interface Employee extends WithId, WithName {
  position: string;
  salary: number;
}
 
function printEmployee(employee: Employee): string {
  return `${employee.name} (${employee.position})`;
}

Такие небольшие интерфейсы, как WithId и WithName, можно переиспользовать для описания самых разных сущностей — сотрудников, клиентов, товаров — не дублируя одинаковые свойства в каждом типе заново.

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

Что дальше

На следующем уроке разберём, как TypeScript работает с промисами: как указывать тип значения, которое вернёт асинхронная функция, и как компилятор помогает находить ошибки при работе с Promise ещё до выполнения кода.

Теги: