Привет! На предыдущем уроке мы изучили типы-пересечения (Intersection Types) — способ комбинировать несколько типов в один. Сегодня мы погрузимся в мир литеральных типов (Literal Types) — одного из самых мощных и элегантных инструментов TypeScript. Если раньше вы работали с типами как с «контейнерами» для значений (например, string — это любая строка), то теперь мы научимся создавать типы, которые соответствуют конкретным значениям!
Представьте, что вы разрабатываете мобильное приложение для доставки еды. У вас есть статус заказа: «создан», «в обработке», «доставлен», «отменён». Вы можете использовать тип string для статуса — но тогда ничто не помешает вам написать "abcxyz" в качестве статуса. А если использовать литеральный тип — TypeScript будет знать, что статус может быть только одним из четырёх допустимых значений!
Литеральные типы — это фундамент для многих продвинутых паттернов в TypeScript: union-типы с ограничением, discriminated unions, template literal types и многое другое. Давайте разберёмся!
Что такое литеральный тип?
Литеральный тип — это тип, который соответствует конкретному значению. В отличие от базовых типов (string, number, boolean), которые описывают класс значений, литеральный тип описывает одно конкретное значение.
Синтаксис предельно прост — просто используйте значение как тип:
// Строка "hello" как тип
type Greeting = "hello";
// Число 42 как тип
type Answer = 42;
// Boolean true как тип
type IsReady = true;
// Присваивание — работает!
const msg: Greeting = "hello"; // ✓ OK
const num: Answer = 42; // ✓ OK
const ready: IsReady = true; // ✓ OK
// Но другое значение — ОШИБКА!
// const msg2: Greeting = "hi"; // ✗ Ошибка!
// const num2: Answer = 100; // ✗ Ошибка!
// const ready2: IsReady = false; // ✗ Ошибка!
Как вы видите, тип Greeting может принимать только значение "hello". Любое другое значение вызовет ошибку компиляции. Это как строгое ограничение — вы говорите TypeScript: «здесь может быть только это конкретное значение, ничего больше».
Аналогия из жизни: представьте, что вы покупаете билет в кино. Билет имеет конкретное место — «ряд 5, место 12». Это и есть литеральный тип: не «любое место в зале» (это было бы как тип Seat), а конкретное место (это литеральный тип "row5-seat12").
📝 Важно: Литеральный тип сам по себе — это не «новая фича», а способ использования уже существующих типов. TypeScript автоматически «сужает» (narrowing) тип до литерального при присваивании константного значения. Например, const x = "hello" автоматически получает тип "hello", а не string.
Строковые литеральные типы (String Literal Types)
Строковые литеральные типы — самый распространённый вид литеральных типов. Они позволяют ограничить строковое значение конкретным набором строк. Это особенно полезно для статусов, ролей, направлений и других перечисляемых значений.
// Определяем строковый литеральный тип
type OrderStatus = "created" | "processing" | "shipped" | "delivered" | "cancelled";
// Используем его
function getStatus(status: OrderStatus): string {
switch (status) {
case "created":
return "Заказ создан";
case "processing":
return "Заказ обрабатывается";
case "shipped":
return "Заказ отправлен";
case "delivered":
return "Заказ доставлен";
case "cancelled":
return "Заказ отменён";
}
}
// ✓ Работает — значение из допустимого набора
const s1: OrderStatus = "created";
const s2: OrderStatus = "shipped";
// ✗ Ошибка — "pending" нет в списке допустимых значений
// const s3: OrderStatus = "pending";
Обратите внимание: тип OrderStatus — это union из строковых литералов. TypeScript понимает, что значение может быть одним из перечисленных строк. Это гораздо безопаснее, чем просто string.
Ещё один пример — HTTP методы:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
function makeRequest(method: HttpMethod, url: string) {
console.log(`Выполняю ${method} запрос к ${url}`);
}
makeRequest("GET", "/api/users"); // ✓ OK
makeRequest("POST", "/api/users"); // ✓ OK
// makeRequest("get", "/api/users"); // ✗ Ошибка! "get" ≠ "GET"
// makeRequest("HEAD", "/api/users"); // ✗ Ошибка! "HEAD" не в списке
📝 Важно: Строковые литеральные типы регистрозависимы! "GET" и "get" — это разные типы. Если вам нужно игнорировать регистр, используйте Uppercase<T> или другие utility types.
Пример с ролями пользователей:
type UserRole = "admin" | "editor" | "viewer";
function checkAccess(role: UserRole, resource: string): boolean {
if (role === "admin") {
return true; // Админ имеет доступ ко всему
}
if (role === "editor") {
return resource !== "settings"; // Редактор не может менять настройки
}
// role === "viewer"
return false; // Зритель только просматривает
}
// Автоматическое завершение (autocomplete) в IDE!
const userRole: UserRole = "ad" // ← IDE предложит "admin"
💡 Совет: Одно из главных преимуществ литеральных типов — автодополнение в IDE. Когда вы пишете const role: UserRole = ", TypeScript подсказывает все возможные значения. Этосильно улучшает DX (developer experience) и снижает количество ошибок.
Практический пример — системные коды ошибок:
type ErrorCode = "NOT_FOUND" | "UNAUTHORIZED" | "FORBIDDEN" | "INTERNAL_ERROR";
interface ApiError {
code: ErrorCode;
message: string;
details?: Record<string, unknown>;
}
function handleError(error: ApiError): string {
switch (error.code) {
case "NOT_FOUND":
return `Ресурс не найден: ${error.message}`;
case "UNAUTHORIZED":
return "Пожалуйста, войдите в систему";
case "FORBIDDEN":
return "У вас нет прав для выполнения этого действия";
case "INTERNAL_ERROR":
return "Произошла внутренняя ошибка сервера";
}
}
const err: ApiError = {
code: "NOT_FOUND",
message: "Пользователь с ID 42 не найден"
};
console.log(handleError(err));
Числовые литеральные типы (Number Literal Types)
Числовые литеральные типы работают аналогично строковым, но ограничивают значение конкретным числом. Это полезно для магических чисел, индексов, кодов HTTP и других ситуаций, когда значение должно быть точно определено.
// Конкретное число как тип
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function rollDice(): DiceRoll {
return Math.floor(Math.random() * 6) + 1 as DiceRoll;
}
const result: DiceRoll = 3; // ✓ OK
// const bad: DiceRoll = 7; // ✗ Ошибка! 7 не в диапазоне
// HTTP статус-коды
type SuccessCode = 200 | 201 | 204;
type ErrorCode4xx = 400 | 401 | 403 | 404 | 422;
type ErrorCode5xx = 500 | 502 | 503;
type HttpStatusCode = SuccessCode | ErrorCode4xx | ErrorCode5xx;
function getStatusMessage(code: HttpStatusCode): string {
switch (code) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 422: return "Unprocessable Entity";
case 500: return "Internal Server Error";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
}
}
// Теперь коды сгруппированы по смыслу!
const status200: SuccessCode = 200; // ✓ OK
const error404: ErrorCode4xx = 404; // ✓ OK
// const bad: SuccessCode = 404; // ✗ Ошибка! 404 — это ErrorCode4xx
Пример с позициями в массиве:
// Позиция в двумерном массиве (матрице)
type Row = 0 | 1 | 2 | 3 | 4;
type Col = 0 | 1 | 2 | 3 | 4;
interface CellPosition {
row: Row;
col: Col;
}
function getCell(position: CellPosition): string {
return `Ячейка [${position.row},${position.col}]`;
}
const pos: CellPosition = { row: 2, col: 3 };
console.log(getCell(pos)); // "Ячейка [2,3]"
// Ошибка — выходит за границы
// const badPos: CellPosition = { row: 5, col: 0 };
💡 Совет: Числовые литеральные типы отлично подходят для устранения магических чисел. Вместо того чтобы писать if (status === 200), используйте именованный тип type SuccessCode = 200. Код становится самодокументирующимся!
Пример с шахматными координатами:
type ChessFile = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h";
type ChessRank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
type ChessSquare = `${ChessFile}${ChessRank}`; // Строка "a1" | "a2" | ... | "h8"
function getSquareColor(square: ChessSquare): "white" | "black" {
const file = square[0] as string;
const rank = parseInt(square[1]);
const fileIndex = file.charCodeAt(0) - 97; // a=0, b=1, ..., h=7
return (fileIndex + rank) % 2 === 0 ? "white" : "black";
}
console.log(getSquareColor("a1")); // "white"
console.log(getSquareColor("h8")); // "white"
console.log(getSquareColor("a8")); // "black"
// getSquareColor("i9"); // ✗ Ошибка!
Булевы литеральные типы (Boolean Literal Types)
Булевы литеральные типы — это типы true или false. Они используются реже строковых и числовых, но имеют важные применения, особенно в conjunction с generics и conditional types.
// Булев литеральный тип
type IsAdmin = true;
type IsEnabled = boolean; // Это.union true | false
interface Config {
readonly: true; // Только true — свойство только для чтения
debug: boolean; // true или false
verbose: false; // Только false — вывод отключён
}
const config: Config = {
readonly: true, // ✓ OK
debug: false, // ✓ OK
verbose: false, // ✓ OK
};
// config.readonly = false; // ✗ Ошибка! readonly: true
// config.verbose = true; // ✗ Ошибка! verbose: false
Булевы литеральные типы особенно полезны с условными типами и перегрузками функций:
// Функция, которая возвращает разные типы в зависимости от флага
function createResponse<T>(data: T, isArray: true): T[];
function createResponse<T>(data: T, isArray: false): T;
function createResponse<T>(data: T, isArray: boolean): T | T[] {
return isArray ? [data] : data;
}
// TypeScript знает тип возвращаемого значения!
const single = createResponse({ id: 1 }, false);
// ^? тип: { id: number }
const array = createResponse({ id: 1 }, true);
// ^? тип: { id: number }[]
// Ошибка — передаём boolean вместо литерала
// createResponse({ id: 1 }, someVariable); // ✗ Ошибка!
⚠️ Предупреждение: Не путайте литеральный тип true с типом boolean. Тип boolean — это true | false, а литеральный true — это только одно значение. Используйте литеральные типы, когда значение должно быть фиксированным.
Утверждение "as const" (Const Assertions)
Утверждение as const — это способ сказать TypeScript: «сделай это значение максимально конкретным». Оно превращает все строки в строковые литералы, все числа — в числовые литералы, и делает все свойства readonly.
// БЕЗ as const — TypeScript сужает типы, но не до литералов
const config1 = {
method: "GET",
timeout: 3000,
};
// Тип: { method: string; timeout: number }
// С as const — типы максимально конкретные
const config2 = {
method: "GET",
timeout: 3000,
} as const;
// Тип: { readonly method: "GET"; readonly timeout: 3000 }
// Теперь нельзя изменить значения
// config2.method = "POST"; // ✗ Ошибка! readonly
// config2.timeout = 5000; // ✗ Ошибка! readonly
Разница между as const и обычным const:
// const — делает переменную неизменяемой (привязка)
const x = 10;
// x = 20; // ✗ Ошибка! const
// as const — делает тип максимально узким (литеральным)
const arr1 = [1, 2, 3];
// Тип: number[] — можно добавлять/менять элементы
arr1.push(4); // ✓ OK
const arr2 = [1, 2, 3] as const;
// Тип: readonly [1, 2, 3] — нельзя менять
// arr2.push(4); // ✗ Ошибка!
// arr2[0] = 99; // ✗ Ошибка!
as const на массивах создаёт tuple-подобные типы:
// Массив без as const
const colors1 = ["red", "green", "blue"];
// Тип: string[] — TypeScript не знает конкретные значения
// Массив с as const
const colors2 = ["red", "green", "blue"] as const;
// Тип: readonly ["red", "green", "blue"]
// Теперь TypeScript знает длину и содержимое!
type Colors = typeof colors2;
// Тип: readonly ["red", "green", "blue"]
// Можно получить литеральные типы из кортежа
type FirstColor = Colors[0]; // "red"
type SecondColor = Colors[1]; // "green"
type ThirdColor = Colors[2]; // "blue"
// Или получить union всех значений
type AnyColor = Colors[number]; // "red" | "green" | "blue"
💡 Совет: as const — это «суперсила» для создания константных объектов и массивов. Используйте его для конфигураций, списков значений, маппингов и любых данных, которые не должны меняться. Это безопаснее, чем просто const, потому что garantирует и неизменяемость, и типизацию.
Практический пример — конфигурация приложения:
// Конфигурация с as const
const AppConfig = {
api: {
baseUrl: "https://api.example.com",
version: "v2",
timeout: 5000,
},
features: {
darkMode: true,
notifications: false,
analytics: true,
},
limits: {
maxUploadSize: 10 * 1024 * 1024, // 10 MB
maxFileSize: 5 * 1024 * 1024, // 5 MB
},
} as const;
// TypeScript знает типы всех значений!
type ApiConfig = typeof AppConfig.api;
// Тип: { readonly baseUrl: "https://api.example.com"; readonly version: "v2"; readonly timeout: 5000 }
// Нельзя изменить — всё readonly
// AppConfig.api.baseUrl = "https://other.com"; // ✗ Ошибка!
Шаблонные литеральные типы (Template Literal Types)
Шаблонные литеральные типы (Template Literal Types) — это одна из самых мощных фич TypeScript 4.1+. Они позволяют создавать строки, используя шаблоны, аналогично шаблонным строкам в JavaScript, но на уровне типов!
// Простой шаблонный литеральный тип
type Name = "Alice" | "Bob";
type Greeting = `Hello, ${Name}!`;
// Тип: "Hello, Alice!" | "Hello, Bob!"
const g1: Greeting = "Hello, Alice!"; // ✓ OK
const g2: Greeting = "Hello, Bob!"; // ✓ OK
// const g3: Greeting = "Hello, Charlie!"; // ✗ Ошибка!
Комбинирование нескольких литеральных типов в шаблоне создаёт декартово произведение:
type Color = "red" | "blue";
type Size = "small" | "large";
type CSSClassName = `${Color}-${Size}`;
// Тип: "red-small" | "red-large" | "blue-small" | "blue-large"
// 4 варианта из 2 × 2!
const className1: CSSClassName = "red-small"; // ✓ OK
const className2: CSSClassName = "blue-large"; // ✓ OK
// const className3: CSSClassName = "green-small"; // ✗ Ошибка!
Создание типизированных CSS-классов:
type TailwindSpacing = "0" | "1" | "2" | "3" | "4" | "6" | "8" | "12" | "16";
type Direction = "t" | "b" | "l" | "r" | "x" | "y";
type PaddingClass = `p-${TailwindSpacing}`;
type MarginClass = `m-${Direction}-${TailwindSpacing}`;
// Padding: "p-0" | "p-1" | "p-2" | ... | "p-16"
// Margin: "mt-0" | "mb-1" | "ml-2" | ... | "my-16"
const p: PaddingClass = "p-4"; // ✓ OK
const m: MarginClass = "mx-8"; // ✓ OK
// const bad: PaddingClass = "p-20"; // ✗ Ошибка! "p-20" нет в списке
Шаблонные литеральные типы с встроенными utility types:
type UppercaseColor = Uppercase<"red" | "blue" | "green">;
// "RED" | "BLUE" | "GREEN"
type LowercaseColor = Lowercase<"RED" | "BLUE" | "GREEN">;
// "red" | "blue" | "green"
type CapitalizedColor = Capitalize<"red" | "blue" | "green">;
// "Red" | "Blue" | "Green"
type UncapitalizedColor = Uncapitalize<"Red" | "Blue" | "Green">;
// "red" | "blue" | "green"
// Комбинируем
type EventName = "click" | "hover" | "focus";
type EventCallback = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"
💡 Совет: Шаблонные литеральные типы — это самая мощная фича для работы со строками на уровне типов. Они позволяют создавать типизированные API-эндпоинты, CSS-классы, имена событий и многое другое. Освойте их — и вы сможете создавать невероятно точные типы!
Практический пример — типизированные API-эндпоинты:
type ApiVersion = "v1" | "v2" | "v3";
type Resource = "users" | "posts" | "comments";
type Endpoint = `/${ApiVersion}/${Resource}`;
// "/v1/users" | "/v1/posts" | ... | "/v3/comments" (18 вариантов)
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Route = `${Method} /${ApiVersion}/${Resource}`;
// "GET /v1/users" | "POST /v1/users" | ... | "DELETE /v3/comments"
function fetchApi<T>(route: Route): Promise<T> {
const [method, path] = route.split(" ");
return fetch(path, { method }).then(r => r.json());
}
// TypeScript проверяет маршрут!
fetchApi("GET /v2/users"); // ✓ OK
fetchApi("POST /v1/posts"); // ✓ OK
// fetchApi("GET /v4/users"); // ✗ Ошибка! v4 не существует
// fetchApi("PATCH /v1/users"); // ✗ Ошибка! PATCH не в списке
Enum vs Литеральные типы: что выбрать?
Часто возникает вопрос: когда использовать enum, а когда — union литеральных типов? Давайте сравним!
// Вариант 1: Enum
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
// Вариант 2: Union литеральных типов
type Direction2 = "UP" | "DOWN" | "LEFT" | "RIGHT";
// Вариант 3: Union литеральных типов с объектом
const Direction3 = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction3 = typeof Direction3[keyof typeof Direction3];
// Тип: "UP" | "DOWN" | "LEFT" | "RIGHT"
Сравнительная таблица:
| Критерий | Enum | Literal Union |
|---|---|---|
| Размер JS-кода | Больше (генерирует объект) | Меньше (стирается) |
| Tree-shaking | Плохо | Отлично |
| Autocomplete | Отлично | Отлично |
| Реверс-маппинг | Да | Нет |
| Runtime-значения | Доступны | Только через объект |
| Вычисляемые значения | Да | Нет |
Когда использовать что:
// ✓ Используйте LITERAL UNION, когда:
// - Нужен минимальный размер бандла
// - Значения — просто строки/числа
// - Нужен tree-shaking
type Status = "active" | "inactive" | "pending";
// ✓ Используйте ENUM, когда:
// - Нужны вычисляемые значения
// - Нужен реверс-маппинг (значение → ключ)
// - Нужны runtime-значения для логики
enum HttpStatus {
OK = 200,
NotFound = 404,
Error = 500,
}
// Реверс-маппинг: 404 → "NotFound"
console.log(HttpStatus[404]); // "NotFound"
⚠️ Предупреждение: Многие эксперты рекомендуют избегать enum в современном TypeScript и использовать union литеральных типов с as const. Enum генерирует лишний JavaScript-код, плохо tree-shакается, и его поведение может быть неожиданным. Если вам не нужен реверс-маппинг или вычисляемые значения — предпочитайте литеральные типы.
Альтернатива enum с as const:
// Объект с as const + typeof = лучшая замена enum
const Status = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
// Получаем union тип из объекта
type Status = typeof Status[keyof typeof Status];
// Тип: "active" | "inactive" | "pending"
// Используем как объект (runtime) и как тип (compile-time)
function getStatusLabel(status: Status): string {
switch (status) {
case Status.Active: return "Активен";
case Status.Inactive: return "Неактивен";
case Status.Pending: return "В ожидании";
}
}
// TypeScript знает все значения!
const s: Status = Status.Active; // ✓ OK
// const bad: Status = "unknown"; // ✗ Ошибка!
Narrowing с литеральными типами (Сужение типов)
Narrowing (сужение типов) — это процесс, при котором TypeScript автоматически определяет более узкий тип на основе проверок в коде. Литеральные типы делают narrowing ещё более мощным!
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript знает, что здесь shape — { kind: "circle"; radius: number }
return Math.PI * shape.radius ** 2;
case "rectangle":
// TypeScript знает, что здесь shape — { kind: "rectangle"; width: number; height: number }
return shape.width * shape.height;
case "triangle":
// TypeScript знает, что здесь shape — { kind: "triangle"; base: number; height: number }
return (shape.base * shape.height) / 2;
default:
// Exhaustive check — TypeScript проверяет, что все варианты обработаны
const _exhaustive: never = shape;
return _exhaustive;
}
}
const circle: Shape = { kind: "circle", radius: 5 };
console.log(getArea(circle)); // 78.53981633974483
Narrowing с if и литеральными типами:
type Response =
| { status: "success"; data: unknown }
| { status: "error"; error: string }
| { status: "loading" };
function handleResponse(response: Response) {
if (response.status === "success") {
// TypeScript знает, что здесь response — { status: "success"; data: unknown }
console.log("Данные:", response.data);
return;
}
if (response.status === "error") {
// TypeScript знает, что здесь response — { status: "error"; error: string }
console.error("Ошибка:", response.error);
return;
}
// Если дошли сюда, response.status === "loading"
console.log("Загрузка...");
}
💡 Совет: Всегда проверяйте все варианты union-типа! TypeScript помогает с switch и if, но вы должны явно обработать каждый случай. Это предотвращает забытые ветки и делает код надёжнее.
Exhaustive checking — убедитесь, что все варианты обработаны:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function handleMethod(method: HttpMethod) {
switch (method) {
case "GET":
console.log("Получение данных");
break;
case "POST":
console.log("Создание данных");
break;
case "PUT":
console.log("Обновление данных");
break;
case "DELETE":
console.log("Удаление данных");
break;
default:
// Если добавить новый метод в HttpMethod и забыть обработать здесь —
// TypeScript покажет ошибку компиляции!
assertNever(method);
}
}
Narrowing с typeof и литеральными типами:
type Primitive = string | number | boolean;
function formatValue(value: Primitive): string {
if (typeof value === "string") {
// TypeScript знает, что value — string
return value.toUpperCase();
}
if (typeof value === "number") {
// TypeScript знает, что value — number
return value.toFixed(2);
}
// TypeScript знает, что value — boolean
return value ? "Да" : "Нет";
}
console.log(formatValue("hello")); // "HELLO"
console.log(formatValue(3.14159)); // "3.14"
console.log(formatValue(true)); // "Да"
Type Aliases с литеральными типами
Type aliases (type) позволяют давать имена литеральным типам, делая код более читаемым и самодокументирующимся.
// Создаём именованные типы для документирования намерений
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
// Функция принимает только UserId
function getUser(id: UserId) {
console.log(`Получаем пользователя ${id}`);
}
// Нельзя передать OrderId в функцию для User!
// getUser(orderId as UserId); // ✗ Ошибка! (если brand не совпадает)
Практический пример — типизированные CSS-переменные:
type CSSColor =
| "red" | "blue" | "green" | "yellow" | "white" | "black"
| "rgba(255,0,0,0.5)" | "rgba(0,0,255,0.5)"
| `#${string}`;
type CSSSize = `${number}px` | `${number}em` | `${number}rem` | `${number}%`;
interface CSSProperties {
color?: CSSColor;
fontSize?: CSSSize;
margin?: CSSSize;
padding?: CSSSize;
backgroundColor?: CSSColor;
}
const buttonStyle: CSSProperties = {
color: "white",
fontSize: "16px",
backgroundColor: "blue",
padding: "12px 24px",
};
// Ошибки — невалидные CSS-значения!
// const badStyle: CSSProperties = { color: "rgb(255,0,0)" }; // ✗ Не поддерживается
// const badSize: CSSProperties = { fontSize: "big" }; // ✗ Не число
Типизированныесобытие-хэндлеры:
type EventName = "click" | "hover" | "focus" | "blur" | "keydown" | "keyup";
type EventHandler = (event: Event) => void;
class EventEmitter {
private handlers: Record<EventName, EventHandler[]> = {
click: [],
hover: [],
focus: [],
blur: [],
keydown: [],
keyup: [],
};
on(event: EventName, handler: EventHandler) {
this.handlers[event].push(handler);
}
emit(event: EventName, eventData: Event) {
this.handlers[event].forEach(handler => handler(eventData));
}
}
const emitter = new EventEmitter();
emitter.on("click", (e) => console.log("Клик!", e));
// emitter.on("scroll", () => {}); // ✗ Ошибка! "scroll" не в списке
Discriminated Unions с литеральными типами
Discriminated Unions (дискриминированные объединения) — это один из самых мощных паттернов в TypeScript. Он комбинирует union-типы, литеральные типы и narrowing для создания безопасных и выразительных структур данных.
// Каждый вариант имеет свой литеральный "discriminant" (дискриминатор)
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; error: string; code: number }
| { status: "loading" };
function handleResult<T>(result: Result<T>) {
// TypeScript автоматически определяет, какой это вариант
switch (result.status) {
case "success":
// result.data доступен — TypeScript знает тип!
console.log("Данные получены:", result.data);
break;
case "error":
// result.error и result.code доступны!
console.error(`Ошибка ${result.code}: ${result.error}`);
break;
case "loading":
// Нет дополнительных полей
console.log("Загрузка...");
break;
}
}
Пример с API-ответами:
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
}
type ApiResponse<T> =
| { type: "success"; data: T; timestamp: number }
| { type: "error"; error: string; status: number }
| { type: "network_error"; retryAfter: number }
| { type: "timeout" };
async function fetchUser(id: number): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return { type: "error", error: response.statusText, status: response.status };
}
const data = await response.json();
return { type: "success", data, timestamp: Date.now() };
} catch (e) {
if (e instanceof TypeError) {
return { type: "network_error", retryAfter: 5000 };
}
return { type: "timeout" };
}
}
async function displayUser(id: number) {
const result = await fetchUser(id);
switch (result.type) {
case "success":
console.log(`Пользователь: ${result.data.name}`); // ✓ data доступен
break;
case "error":
console.error(`Ошибка ${result.status}: ${result.error}`); // ✓ error доступен
break;
case "network_error":
console.log(`Повтор через ${result.retryAfter}мс`); // ✓ retryAfter доступен
break;
case "timeout":
console.log("Превышено время ожидания");
break;
}
}
Discriminated unions с Exhaustive checks:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// Если добавить новый kind в Shape и забыть обработать —
// TypeScript покажет ошибку!
const _exhaustive: never = shape;
return _exhaustive;
}
}
// Если добавить "pentagon" в Shape:
// type Shape = ... | { kind: "pentagon"; sides: number };
// TypeScript покажет ошибку в default-ветке!
💡 Совет: Discriminated unions — это золотой стандарт для обработки множества вариантов в TypeScript. Они заменяют switch-case по строкам, делая кодтипобезопасный и самодокументирующимся. Всегда используйте литеральный тип в качестве дискриминатора!
Практические паттерны с литеральными типами
Давайте рассмотрим реальные паттерны, которые вы будете использовать каждый день:
Паттерн 1: Type-safeсобытиеы
// Типизированная система событий
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; amount: number };
"order:shipped": { orderId: string; trackingNumber: string };
};
class TypedEventEmitter<T extends Record<string, unknown>> {
private handlers: { [K in keyof T]?: ((data: T[K]) => void)[] } = {};
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event]!.push(handler);
}
emit<K extends keyof T>(event: K, data: T[K]) {
this.handlers[event]?.forEach(handler => handler(data));
}
}
const events = new TypedEventEmitter<EventMap>();
// TypeScript проверяет тип данных!
events.on("user:login", (data) => {
console.log(`Пользователь ${data.userId} вошёл в систему`);
// data.timestamp доступен — TypeScript знает тип!
});
events.emit("user:login", { userId: "123", timestamp: Date.now() });
// events.emit("user:login", { userId: "123" }); // ✗ Ошибка! Нет timestamp
Паттерн 2: Builder с литеральными типами
// Типизированный builder для HTTP-запросов
interface RequestBuilder<Method extends string = never, URL extends string = never> {
method<M extends string>(): RequestBuilder<M, URL>;
url<U extends string>(): RequestBuilder<Method, U>;
build(): { method: Method; url: URL };
}
class RequestBuilderImpl implements RequestBuilder {
private _method = "";
private _url = "";
method<M extends string>(): RequestBuilder<M, string> {
this._method = "" as M;
return this as any;
}
url<U extends string>(): RequestBuilder<string, U> {
this._url = "" as U;
return this as any;
}
build() {
return { method: this._method, url: this._url };
}
}
// TypeScript проверяет порядок вызовов!
const request = new RequestBuilderImpl()
.method<"GET">()
.url<"/api/users">()
.build();
// Тип: { method: "GET"; url: "/api/users" }
Паттерн 3: Type-safe настройки
// Конфигурация с автоматическим выводом типов
const defaultConfig = {
theme: "light" as const,
language: "ru" as const,
notifications: true as const,
maxRetries: 3 as const,
} satisfies Record<string, unknown>;
type Config = typeof defaultConfig;
// Функция для обновления конфигурации
function updateConfig<K extends keyof Config>(
key: K,
value: Config[K]
): Config {
// ... обновление
return { ...defaultConfig, [key]: value };
}
// TypeScript знает тип каждого ключа!
updateConfig("theme", "dark"); // ✓ OK (если "dark" в допустимых)
updateConfig("maxRetries", 5); // ✓ OK
// updateConfig("theme", 123); // ✗ Ошибка! theme — строка
// updateConfig("unknown", "x"); // ✗ Ошибка! "unknown" не в ключах
Типичные ошибки и как их избежать
Ошибка 1: Забытый кейс в switch
// ✗ Плохо — нет проверки всех вариантов
type Status = "active" | "inactive" | "pending";
function getStatusLabel(status: Status): string {
switch (status) {
case "active": return "Активен";
case "inactive": return "Неактивен";
// Где pending?! TypeScript НЕ покажет ошибку!
}
}
// ✓ Хорошо — exhaustive check
function getStatusLabel(status: Status): string {
switch (status) {
case "active": return "Активен";
case "inactive": return "Неактивен";
case "pending": return "В ожидании";
default:
const _exhaustive: never = status;
return _exhaustive;
}
}
Ошибка 2: Использование enum вместо литерального union
// ✗ Плохо — enum генерирует лишний JS-код
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
// ✓ Хорошо — литеральный union + const object
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction = typeof Direction[keyof typeof Direction];
Ошибка 3: Потеря литерального типа при присваивании
// ✗ Плохо — переменная получает широкий тип
const status = "active";
// Тип: string (не "active"!)
// ✓ Хорошо — as const сохраняет литеральный тип
const status = "active" as const;
// Тип: "active"
// ✓ Или используйте явную аннотацию
const status: "active" = "active";
Ошибка 4: Смешивание enum и литеральных типов
// ✗ Плохо — enum и литеральный union в одном коде
enum Status1 { Active, Inactive }
type Status2 = "active" | "inactive";
// Функция не может принимать оба типа!
function process(status: Status1 | Status2) {
// Придётся проверять typeof — неудобно
}
// ✓ Хорошо — выберите ОДИН подход и используйте его везде
type Status = "active" | "inactive";
⚠️ Предупреждение: Не создавайте литеральные типы с сотнями значений! Если у вас более 20-30 литералов, подумайте о string с валидацией в рантайме. Литеральные типы — для фиксированного набора значений, а не для пользовательского ввода.
Итоги урока
Отлично! Мы подробно изучили литеральные типы — один из самых мощных инструментов TypeScript. Давайте подведём итоги:
- Литеральный тип — это тип, соответствующий конкретному значению (строке, числу, boolean)
- Union литеральных типов ограничивает значение фиксированным набором допустимых вариантов
- Шаблонные литеральные типы позволяют создавать строки по шаблону на уровне типов
as constсохраняет литеральные типы и делает значения readonly- Discriminated unions комбинируют литеральные типы с union для безопасной обработки множества вариантов
- Narrowing позволяет TypeScript автоматически определять точный тип на основе проверок
🎯 Что дальше: На следующем уроке мы изучим условные типы (Conditional Types) — ещё более продвинутый инструмент, который позволяет создавать типы на основе условий. Литеральные типы — основа для понимания условных типов, поэтому вы уже сделали большой шаг!
Практическое задание
Попрактикуйтесь! Создайте:
- Тип
WeekDay— union из литералов "Monday"..."Sunday" - Discriminated union
Actionс вариантами "increment", "decrement", "reset" - Шаблонный литеральный тип
EventHandlerдля имён событий "onClick", "onSubmit" и т.д. - Функцию
calculateс discriminated union для разных типов вычислений
Тест по Литеральным типам
10 вопросов