Основы TypeScript: от первой аннотации до генериков и строгого режима
Структурированный обзор системы типов TypeScript: от примитивов и автоматического определения типов до дискриминированных союзов, генериков, вспомогательных типов и строгой настройки tsconfig.
Каждый разработчик JavaScript знаком с этой ситуацией: код работает локально, его отправляют в продакшн, и через два дня поступает отчет о баге — функция получила объект вместо строки или в расчёт попало значение undefined. JavaScript не мешает вам писать такой код; он просто вызывает ошибку позже, во время выполнения, обычно в продакшене. TypeScript переносит момент возникновения ошибки на момент ввода соответствующей строки кода. Этот гид проведёт вас от первой записи let x: number через механизмы узкого определения типов, дискриминированные союзы, генерики и вспомогательные типы до настроек компилятора, которые определяют степень защиты кода, чтобы вы могли с уверенностью читать и писать код на TypeScript для продакшена.
Что такое TypeScript и чем он не является
TypeScript — это язык с открытым исходным кодом от Microsoft, добавляющий статическую систему типов к JavaScript. Под «статической» здесь подразумевается то, что компилятор проверяет типы до выполнения кода — в редакторе или во время компиляции, а не при его выполнении. Три основных факта определяют всё остальное:
- Это суперсет JavaScript. Любой синтаксически корректный код на JavaScript также является корректным с точки зрения синтаксиса TypeScript, поэтому вы расширяете уже известные вам знания, а не начинаете с нуля. Однако проверщик всё равно может выдавать ошибки для такого кода, и это как раз и является целью.
- Он компилируется в обычный JavaScript. Браузеры и Node.js работают с JavaScript, поэтому компилятор
tscудаляет аннотации и генерирует обычные файлы.js.
Разница в одной функции
Вот обычная функция JavaScript, которая складывает два значения:
function add(a, b) {
return a + b;
}
При вызове с строкой и числом она не выбрасывает ошибку. Она объединяет значения, и вы замечаете проблему только тогда, когда сумма на счёт-фактуре кажется странной:
add("10", 20); // "1020" — silently wrong
Указание типов параметров и возвращаемого значения чётко определяет контракт:
function add(a: number, b: number): number {
return a + b;
}
Теперь тот же вызов отклоняется ещё на этапе редактирования, причина ошибки отображается непосредственно под проблемным аргументом:
add("10", 20);
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.
Настройка проекта
После установки Node.js вы можете установить компилятор глобально и проверить его работоспособность:
npm install -g typescript
tsc --version
Для реальных проектов установите TypeScript в качестве локальной зависимости для разработки, чтобы все участники проекта и сервер CI использовали одну и ту же версию, затем сгенерируйте файл конфигурации:
npm install typescript --save-dev
npx tsc --init
Сгенерированный файл tsconfig.json служит панелью управления тем, насколько строгим и современным должен быть компилятор. В новых версиях команды tsc --init уже включен режим strict, а в TypeScript 6.0 строгий режим стал стандартным, однако в более старых проектах часто используются более либеральные настройки, которые команды позже ужесточают. В разделе конфигурации ниже рассматривается этот аспект.
Минимальный первый файл объявляет переменную с типизацией и выводит её в лог:
// app.ts
let message: string = "Hello, TypeScript";
console.log(message);
Скомпилируйте его, затем запустите сгенерированный JavaScript:
tsc app.ts # produces app.js
node app.js # Hello, TypeScript
В повседневной практике большинство проектов избегают этой двухэтапной процедуры и запускают файлы с помощью ts-node или tsx, либо используют инструменты сборки вроде Vite, esbuild или webpack, которые выполняют компиляцию в реальном времени. Тем не менее стоит сделать это вручную хотя бы один раз, так как это помогает сформировать правильное представление: входит TypeScript, выходит JavaScript.
Основные типы и автодополнение
Примитивы и массивы
Аннотации для примитивов — это string, number и boolean:
let username: string = "sanajit";
let age: number = 26;
let isActive: boolean = true;
Массивы записываются с указанием типа элемента за квадратными скобками:
let scores: number[] = [90, 85, 78];
let tags: string[] = ["typescript", "javascript"];
Генерическая форма Array<number> означает точно то же самое; выберите один стиль и используйте его последовательно:
// equivalent generic syntax
let ids: Array<number> = [1, 2, 3];
Позвольте автодополнению выполнять очевидную работу
Редко бывает необходимость аннотировать инициализированные переменные. TypeScript определяет тип на основе значения, а затем обеспечивает его соблюдение:
let city = "Ahmedabad"; // inferred as string
city = 42; // Error: Type 'number' is not assignable to type 'string'
Запись let city: string = „Ahmedabad“ не является ошибкой, но избыточна. Хорошее правило для всего языка: полагаться на автоматическое определение типа, когда значение само по себе ясно указывает на тип, а указывать явные типы там, где значение неоднозначно или когда вы определяете структуру, такую как параметры функций, типы возвращаемых значений, пустые массивы и сигнатуры обратных вызовов.
any, unknown, never и void
Эти четыре специальных типа становятся причиной затруднений у практически каждого начинающего:
anyотключает проверку типов для данного значения. Это скорее способ выхода из сложной ситуации, чем настоящий тип; чрезмерное использование этого типа — самая распространенная причина того, что кодовая база превращается в JavaScript с дополнительной синтаксисом.
unknown является безопасным аналогом. Ему можно присвоить любое значение, но использовать его нельзя до тех пор, пока вы не уточните его с помощью проверки. Этот вариант подходит для данных, форма которых пока неизвестна, например, для ответа API до его верификации.never означает значение, которое не может существовать, например, результат функции, которая всегда вызывает исключение или никогда не возвращает значение. Его наиболее практичное применение — проверка полноты охвата, позволяющая компилятору убедиться, что обработаны все случаи использования switch.void обозначает функцию, которая не возвращает ничего полезного, такую как обработчик событий или обертка для логирования.При использовании unknown проверка typeof разрешает использование строковых методов только внутри защищенной ветви:
function process(value: unknown) {
if (typeof value === "string") {
console.log(value.toUpperCase()); // safe — narrowed to string
}
}
Функция, которая всегда выбрасывает исключение, имеет тип never, в то время как функция, выполняющая лишь побочный эффект, возвращает void:
function fail(message: string): never {
throw new Error(message);
}function logAction(action: string): void {
console.log(`Action: ${action}`);
}
Чтобы узнать конкретные способы замены any в распространённых ситуациях, ознакомьтесь с шестью безопасными по типу шаблонами замены any.
Описание объектов с помощью интерфейсов и псевдонимов типов
Структуру объекта можно описать встроенно:
const employee: { id: number; name: string; active: boolean } = {
id: 1,
name: "Amit",
active: true,
};
Это работает один раз, но постоянное использование одной и той же встроенной структуры везде быстро превращается в избыточность. Решением является назначение этой структуры имени с помощью interface или type.
Интерфейсы
Интерфейс задаёт структуру объекта, что позволяет использовать её повторно:
interface Employee {
id: number;
name: string;
department: string;
}
Каждый объект, помеченный этим интерфейсом, должен соответствовать объявленным полям:
const employee1: Employee = { id: 1, name: "John", department: "IT" };
const employee2: Employee = { id: 2, name: "Sara", department: "HR" };
Знак вопроса обозначает свойство, которое может отсутствовать:
interface User {
id: number;
name: string;
phone?: string; // may or may not be present
}
Свойства с модификатором readonly можно присваивать при создании объекта, но не позже этого момента:
interface Product {
readonly id: number;
name: string;
}
Попытка переопределить такое свойство приводит к ошибке компиляции:
const laptop: Product = { id: 100, name: "MacBook" };
laptop.id = 200; // Error: Cannot assign to 'id' because it is a read-only property
Интерфейсы также могут расширять друг друга, что позволяет делиться общими полями без их копирования. Начните с базовой структуры:
interface Person {
name: string;
age: number;
}
Затем создайте более специфическую структуру на её основе:
interface Employee extends Person {
employeeId: number;
department: string;
}
Псевдонимы типов
Псевдоним типа может использоваться также для обозначения структур объектов, но не ограничивается ими. Таким образом можно дать имя объединениям, примитивам и кортежам:
type ID = string | number;
type Point = { x: number; y: number };
type Status = "pending" | "shipped" | "delivered";
Выбор между интерфейсом и типом
Для обычных структур объектов эти понятия во многом взаимозаменяемы. Реальные различия заключаются в следующем:
- Расширение: интерфейсы используют оператор
extends; псевдонимы типов объединяют структуры с помощью оператора пересечения&. - Объединения и примитивы: их могут представлять только псевдонимы типов, как в примере
type A = string | number. - Слияние деклараций: два интерфейса с одинаковым именем сливаются в один; псевдонимы типов нельзя передекларировать.
- Конвенция: интерфейсы обычно используются для описания структур публичного API и контрактов классов; псевдонимы типов — для объединений, туплей и условных или отображаемых типов.
Многие команды придерживаются правила: использовать interface, когда структура, скорее всего, будет расширяться, например для свойств React, моделей API и контрактов классов, а type — для объединений, туплей и всех типов, отличных от объектов.
Объединения, сужение и пересечения
Типы объединений
Союз может представлять собой один из нескольких типов:
function printId(id: string | number) {
console.log(`Your ID is ${id}`);
}
Оба приведённых ниже вызова допускаются, поскольку каждый аргумент соответствует одному из элементов союза:
printId(101);
printId("A-204");
Узкое определение типа
Когда значение имеет тип союза, TypeScript разрешает выполнять только те операции, которые допустимы для всех его элементов, пока не будет установлено, какой именно элемент используется. Эта процедура называется узким определением типа, и компилятор следует ей на протяжении всего контрольного потока:
function formatValue(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows it's a string here
}
return value.toFixed(2); // and here, it knows it's a number
}
Помимо typeof, распространёнными инструментами узкого определения типа являются Array.isArray(), instanceof, оператор in и проверки на равенство с null или undefined. Именно так TypeScript в обычных случаях молчит, но всё равно выявляет необычные ситуации.
Дискриминированные союзы для состояний
Самая распространённая ошибка, которую допускают разработчики среднего уровня, — это предоставление каждой варианте объединения общего литерального поля «тег». Сначала определите каждое состояние отдельно:
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
Затем объедините их и будете переключаться по тегу:
type FetchState = LoadingState | SuccessState | ErrorState;function render(state: FetchState) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return `Loaded ${state.data.length} items`;
case "error":
return `Failed: ${state.message}`;
}
}
Внутри каждого case значение state ограничивается соответствующей вариантой, поэтому state.data доступен только в ветке "success", а state.message — только в ветке "error". Невозможные комбинации, такие как наличие данных и ошибки одновременно, просто не могут быть представлены. Это исключает целую категорию сбоев типа «свойство не определено» в коде интерфейса и API. Добавление ветки default, которая присваивает state переменной never, превращает это в проверку полноты охвата: если позже добавить четвертое состояние, компилятор указывает на все операторы switch, которые его упустили.
Типы пересечения
Если объединение означает «это или то», то пересечение означает «это и то». Начнем с двух простых форм:
type Timestamped = { createdAt: Date };
type Named = { name: string };
Для создания пересечения объект должен содержать поля обоих типов:
type Record = Timestamped & Named;const item: Record = { name: "Invoice", createdAt: new Date() };
Одно предупреждение по этому примеру: Record — это также имя встроенного типа. Если вы объявите свой собственный псевдоним с этим именем, он заслонит глобальный тип в данном файле, что в лучшем случае вызовет путаницу; поэтому в реальном коде лучше использовать более конкретное имя.
Функции
Аннотации параметров и возвращаемых значений являются основой контракта функции:
function multiply(a: number, b: number): number {
return a * b;
}
Для необязательных параметров используется ?; значение по умолчанию делает параметр необязательным и определяет его тип, а параметры rest собирают любое количество аргументов в массив определенного типа:
// optional parameter
function greet(name: string, title?: string): string {
return title ? `${title} ${name}` : name;
}// default parameter
function createOrder(item: string, quantity: number = 1) {
return { item, quantity };
}// rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
Типы функций
Вы также можете описать структуру самой функции, что удобно для обратных вызовов и объектов-стратегий:
type MathOperation = (a: number, b: number) => number;
Функция, присвоенная этому типу, берет типы своих параметров из аннотации, поэтому a и b не требуют собственных аннотаций:
const subtract: MathOperation = (a, b) => a - b;
Классы и модификаторы доступа
Классы TypeScript — это классы JavaScript с типизированными свойствами и модификаторами доступа. В приведенном ниже классе сначала объявляются поле balance с ограничением на приватный доступ и свойство owner с ограничением на чтение только:
class Account {
private balance: number;
readonly owner: string;
Остальная часть класса задает значения этих полей в конструкторе и предоставляет методы для изменения и чтения значения balance; доступ к приватному полю извне запрещен:
constructor(owner: string, initialBalance: number) {
this.owner = owner;
this.balance = initialBalance;
} deposit(amount: number): void {
this.balance += amount;
} getBalance(): number {
return this.balance;
}
}const acc = new Account("Priya", 1000);
acc.deposit(500);
console.log(acc.getBalance()); // 1500
acc.balance; // Error: Property 'balance' is private
Модификаторы означают следующее:
public, что является значением по умолчанию, доступен везде.privateдоступен только внутри класса.protectedдоступен внутри класса и его подклассов.
readonly поля принимают значение в конструкторе и не позволяют изменять его позже.Имейте в виду, что ограничение private реализуется только компилятором; во время выполнения свойство является обычным полем. Если вам нужна настоящая приватность во время выполнения, для этого предназначены поля # в JavaScript — о сравнении этих подходов рассказано в статье «Private fields в TypeScript против синтаксиса #».
Интерфейсы как контракты классов
Интерфейс может определить, что должен обеспечивать класс:
interface Shape {
area(): number;
}
С помощью implements компилятор проверяет, что класс соблюдает установленный контракт, и сообщает о отсутствующем методе до запуска кода. Конструктор также использует свойство-параметр private radius, которое одновременно объявляет и присваивает значение полю:
class Circle implements Shape {
constructor(private radius: number) {} area(): number {
return Math.PI * this.radius ** 2;
}
}
Генерики: переиспользуемый код, сохраняющий свои типы
Именно с генериками TypeScript перестаёт казаться просто аннотированным JavaScript и становится похожим на отдельный инструмент.
Проблема, которую они решают
Вспомогательная функция, возвращающая первый элемент любого массива, может быть написана с использованием any:
function firstElement(arr: any[]) {
return arr[0];
}
Она работает, но информация о типе теряется при выходе из функции, и каждый результат имеет тип any:
const num = firstElement([1, 2, 3]); // typed as `any` — no help from the compiler
const str = firstElement(["a", "b"]); // also `any`
Параметр типа фиксирует тип элемента входных данных и использует его для значения возвращаемого результата:
function firstElement<T>(arr: T[]): T {
return arr[0];
}
Теперь каждый вызов возвращает результат с точно определённым типом, который выводится на основе аргумента:
const num = firstElement([1, 2, 3]); // inferred as number
const str = firstElement(["a", "b"]); // inferred as string
T — это переменная типа, которую TypeScript заполняет на основе переданных данных. Вы сохраняете гибкость типа any и получаете надежность конкретного типа. Обратите внимание, что при использовании пустого массива значение arr[0] на этапе выполнения фактически равно undefined; включение опции noUncheckedIndexedAccess заставляет компилятор отражать это как T | undefined.
Генерические интерфейсы
Генерики также работают с интерфейсами. Один обёрточный класс может описывать любой конечный пункт:
interface ApiResponse<T> {
success: boolean;
data: T;
}
Тип данных загрузки указывается там, где используется обёртка:
const userResponse: ApiResponse<{ id: number; name: string }> = {
success: true,
data: { id: 1, name: "Sara" },
};
Так многие приложения формируют структуру своего API-слоя: один объект типа ApiResponse<T> переиспользуется для пользователей, продуктов, заказов и всего остального, что возвращает бэкенд.
Ограничение параметра типа
Иногда T должен гарантировать определённые свойства. Опишите это требование в виде интерфейса:
interface HasLength {
length: number;
}
Затем ограничьте параметр с помощью extends, чтобы принимались только типы с атрибутом length:
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}logLength("hello"); // OK — strings have .length
logLength([1, 2, 3]); // OK — arrays have .length
logLength(42); // Error: number doesn't have .length
Вспомогательные типы
TypeScript поставляет генерические вспомогательные типы, которые создают новые типы на основе существующих, заменяя большое количество ручно написанного шаблонного кода. Учитывая модель пользователя:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
Можно создавать варианты вместо их повторного объявления: Partial делает все свойства необязательными, Readonly делает их все только для чтения, Pick сохраняет выбранные ключи, Omit удаляет их, а Record формирует тип словаря:
// Every property becomes optional — perfect for "update" functions
type UserUpdate = Partial<User>;// Every property becomes read-only
type ImmutableUser = Readonly<User>;// Pick only the fields you need
type UserPreview = Pick<User, "id" | "name">;// Everything except the fields you list
type PublicUser = Omit<User, "email" | "isAdmin">;// A dictionary shape: keys of one type, values of another
type UsersById = Record<number, User>;
Типичным примером использования является функция обновления, принимающая любой подмножество полей:
function updateUser(id: number, changes: Partial<User>): void {
// merge `changes` into the stored user
}
Вызывающие передают только те изменения, которые необходимы:
updateUser(1, { name: "New Name" }); // no need to pass email, isAdmin, etc.
Руководство блога по встроенным в TypeScript типам-утилитам подробнее рассматривает весь набор таких типов.
Энумерации и литеральные типы
Энумерации
Энумерация определяет названный набор констант:
enum OrderStatus {
Pending,
Shipped,
Delivered,
}
Затем к значениям обращаются через энумерацию:
let status: OrderStatus = OrderStatus.Shipped;
По умолчанию элементы энумерации нумеруются с 0. Присвоение строковых значений значительно упрощает отладку логов и сетевых данных:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
Объединения строковых литералов часто проще
Многие команды сейчас предпочитают использовать объединения строковых литералов, поскольку это не генерирует дополнительного JavaScript и обеспечивает более предсказуемое поведение:
type OrderStatus = "pending" | "shipped" | "delivered";
Компилятор по-прежнему отклоняет любые значения, находящиеся вне заданного набора:
function updateStatus(status: OrderStatus) {
// ...
}updateStatus("shipped"); // OK
updateStatus("cancelled"); // Error: not assignable to type 'OrderStatus'
Есть ещё одна практическая причина отдавать предпочтение союзам: enum генерируют код во время выполнения, поэтому инструменты, которые удаляют только типы, такие как встроенная поддержка TypeScript в Node.js, не принимают их.
Модули и tsconfig.json
Модули
TypeScript использует стандартную синтаксис ES-модулей. Один файл экспортирует функцию:
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
Другой файл импортирует её:
// app.ts
import { add } from "./math";
Наиболее важные настройки
Генерируемая конфигурация содержит десятки опций, но лишь несколько из них влияют на большинство аспектов работы с TypeScript:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
}
}
strict: trueвключает всю серию строгих проверок, включаяstrictNullChecks, которая обязывает явно обрабатывать значенияnullиundefined. Опытные разработчики считают это обязательным условием, поскольку без него TypeScript выявляет гораздо меньше реальных ошибок.
noImplicitAny фиксирует ошибку в тех случаях, когда значение иначе автоматически принимало бы значение any, если бы вы этого не запрашивали. Он уже входит в состав режима strict, поэтому его отдельное указание имеет значение лишь для лучшей читаемости.target определяет, какая версия JavaScript будет использоваться, а значит, насколько современная синтаксис будет переписана для более старых сред выполнения.Когда вы наследуете устаревшую кодовую базу с отключенным режимом strict, правильный подход — включить его и постепенно устранять ошибки, редактируя файл за файлом, а не использовать значение any, пока не исчезнут предупреждения.
Распространенные ошибки на каждом уровне
Новички
- Добавление аннотаций к значениям, которые TypeScript и так определял бы сам.
- Использование значения
anyсразу при появлении ошибок от компилятора вместо уточнения реального типа.
Средний уровень
- Объявление
interfaceилиtypeдля одноразовой структуры, которую должен обработать механизм инференции, что добавляет формальности без реальной пользы. - Определение массивов как изменяемых и их модификация с помощью
pushилиsplice, в то время как им следует бытьReadonlyArray<T>и обновляться без изменений. - Забывание о том, что объединение типов должно быть сужено перед тем, как станут доступны элементы конкретного типа, и противостояние компилятору вместо изучения его сообщений об ошибках.
Продвинутый уровень
- Написание генериков без ограничений, которые могут представлять собой что угодно, что тайно сводит на нет цель использования генериков в функциях.
- Отключение режима
strictдля всего проекта с целью скрытия нескольких ошибок вместо их устранения или ограничения диапазона их влияния. - Рассматривание операций утверждения типа, таких как
value as Type, как если бы они что-то проверяли. Утверждение ничего не проверяет во время выполнения; оно лишь просит компилятор поверить вам. Данные извне программы, включая ответы API, данные от пользователя иlocalStorage, требуют реальной проверки.
Основные выводы
- TypeScript добавляет к JavaScript проверку на этапе компиляции; типы исчезают при запуске кода, поэтому проверки на этапе выполнения всё равно необходимы.
- Аннотируйте контракты, такие как сигнатуры функций и пустые коллекции, а остальное пусть обрабатывает механизм вывода типов.
- Используйте
interfaceдля расширяемых структур объектов иtypeдля объединений, туплей и типов, отличных от объектов. - Учитеся использовать дискриминированные объединения с самого начала; это паттерн, который наиболее эффективно предотвращает ошибки в коде с большим количеством состояний.
- Генерические и вспомогательные типы, такие как
Partial,Pick,OmitиRecord, превращают разрозненные аннотации в настоящую систему типов. - Сохраняйте значение
strict: true. Сильный режим TypeScript позволяет обнаруживать ошибки на ранних этапах, что исключает необходимость их исправления позже с большими затратами, и это возможно только тогда, когда проверщик имеет полномочия выполнять свою работу.
Связанные материалы
- Шесть техник TypeScript, превращающих типы в эффективные средства предотвращения ошибок — Узнайте, как функции satisfies, тегированные объединения, механизм never checks, тип unknown, производные типы и специальные идентификаторы помогают TypeScript находить реальные ошибки на этапе компиляции, а не в рабочей среде.
- Моделирование доменов в TypeScript: за пределами базовых аннотаций типов — Ознакомьтесь с практическими подходами в TypeScript — от использования значений unknown и any до дискриминированных союзов и оператора satisfies — которые помогают моделировать корректные состояния вместо простого маркирования данных.
- Основы циклов в TypeScript: while, do-while, for, break и continue — Узнайте, как циклы while, do-while и for в TypeScript оценивают свои условия, почему изменение области видимости переменной цикла влияет на результаты и как операторы break и continue изменяют ход выполнения кода.
- Объяснение принципов равенства в JavaScript: ===, ==, операторы сравнения и Object.is — Узнайте, как строгое и нестрогое сравнение равенства, операторы сравнения и Object.is на самом деле определяют идентичность в JavaScript, а также о ловушках принудительной трансформации данных, скрытых в каждом из них.