Литеральные типы в TypeScript
Литеральный тип — это тип, который допускает ровно одно значение — например строку "north" или число 1 — вместо любой строки или любого числа.
Как понимать литеральные типы в TypeScript
Литеральные типы позволяют точно указать, какие значения может хранить переменная. Они точнее, чем широкие типы string, number или boolean.
Они являются строительными блоками для точного и типобезопасного кода.
Ключевые понятия:
- Строковые литералы: конкретные строковые значения, например
"success" | "error" - Числовые литералы: конкретные числа, например
1 | 2 | 3 - Логические литералы: либо
true, либоfalse - Шаблонные литеральные типы: строковые литеральные типы, построенные с помощью синтаксиса шаблонных строк
Типичные случаи применения:
- Определение фиксированного набора допустимых значений
- Создание размеченных объединений
- Типобезопасная обработка событий
- Типизация ответов API
- Объекты конфигурации
Строковые литеральные типы
Строковый литеральный тип обозначает одно конкретное строковое значение. Обычно несколько таких типов объединяют с помощью | в объединение допустимых значений.
Пример
direction может хранить только одну из четырёх строк. Любая другая строка, например "northeast", вызывает ошибку компиляции — как при присваивании переменной, так и при вызове move.
direction с типом "north" | "south" | "east" | "west", присвойте "north", а затем "south", и оставьте закомментированными присваивания "northeast" и "up" вместе с ошибками, которые они бы вызвали. Напишите функцию move, принимающую то же литеральное объединение и выводящую Движение <direction>, вызовите её с "east" и оставьте move("up") закомментированным как недопустимый вызов.
Вывод: программа на TypeScript и захваченный вывод
// Переменная со строковым литеральным типом
let direction: "north" | "south" | "east" | "west";
// Допустимые присваивания
direction = "north";
direction = "south";
// Недопустимые присваивания вызвали бы ошибки
// direction = "northeast"; // Ошибка: тип '"northeast"' нельзя присвоить типу '"north" | "south" | "east" | "west"'
// direction = "up"; // Ошибка: тип '"up"' нельзя присвоить типу '"north" | "south" | "east" | "west"'
// Использование строковых литеральных типов в функциях
function move(direction: "north" | "south" | "east" | "west") {
console.log(`Движение ${direction}`);
}
move("east"); // Допустимо
// move("up"); // Ошибка: аргумент типа '"up"' нельзя присвоить параметру типа...
Числовые литеральные типы
Так же как и строковые литералы, числовые литеральные типы обозначают конкретные числовые значения.
Пример
diceRoll принимает только числа от 1 до 6. Поскольку Math.random() возвращает обычный number, функции rollDice нужно утверждение типа, чтобы пообещать, что она возвращает один из шести литералов. Выпавшее значение меняется при каждом запуске.
diceRoll с типом 1 | 2 | 3 | 4 | 5 | 6, присвойте 1, а затем 6, и оставьте закомментированными присваивания 0, 7 и 2.5 вместе с их ошибками. Напишите rollDice(), возвращаемым типом которой является то же объединение и которая возвращает Math.floor(Math.random() * 6) + 1, приведённое к этому объединению, затем выведите Выпало: <result> (число случайно при каждом запуске).
Вывод: программа на TypeScript и захваченный вывод
// Переменная с числовым литеральным типом
let diceRoll: 1 | 2 | 3 | 4 | 5 | 6;
// Допустимые присваивания
diceRoll = 1;
diceRoll = 6;
// Недопустимые присваивания вызвали бы ошибки
// diceRoll = 0; // Ошибка: тип '0' нельзя присвоить типу '1 | 2 | 3 | 4 | 5 | 6'
// diceRoll = 7; // Ошибка: тип '7' нельзя присвоить типу '1 | 2 | 3 | 4 | 5 | 6'
// diceRoll = 2.5; // Ошибка: тип '2.5' нельзя присвоить типу '1 | 2 | 3 | 4 | 5 | 6'
// Использование числовых литеральных типов в функциях
function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
return Math.floor(Math.random() * 6) + 1 as 1 | 2 | 3 | 4 | 5 | 6;
}
const result = rollDice();
console.log(`Выпало: ${result}`);
Логические литеральные типы
Логические литеральные типы используются реже, поскольку логических значений всего два, но в некоторых случаях они полезны — например чтобы указать, что функция всегда возвращает true.
Пример
alwaysSucceed обязана вернуть литерал true. SuccessFlag и FailureFlag смешивают логические, строковые и числовые литералы, а processResult различает их с помощью простых проверок на равенство.
type YesOnly = true и функцию alwaysSucceed(), возвращаемым типом которой является литерал true. Затем объявите SuccessFlag = true | "success" | 1 и FailureFlag = false | "failure" | 0, а также функцию processResult, принимающую любой из них и выводящую Операция выполнена успешно, когда значение равно true, "success" или 1, и Операция не выполнена в противном случае. Вызовите её с true, "success", 1 и false.
Вывод: программа на TypeScript и захваченный вывод
// Тип, который может быть только литеральным значением 'true'
type YesOnly = true;
// Функция, которая обязана вернуть true
function alwaysSucceed(): true {
// Всегда возвращает литеральное значение 'true'
return true;
}
// Логический литерал в сочетании с другими типами
type SuccessFlag = true | "success" | 1;
type FailureFlag = false | "failure" | 0;
function processResult(result: SuccessFlag | FailureFlag) {
if (result === true || result === "success" || result === 1) {
console.log("Операция выполнена успешно");
} else {
console.log("Операция не выполнена");
}
}
processResult(true); // "Операция выполнена успешно"
processResult("success"); // "Операция выполнена успешно"
processResult(1); // "Операция выполнена успешно"
processResult(false); // "Операция не выполнена"
Литеральные типы в объектах
Литеральные типы можно использовать как типы свойств внутри типов объектов. Это даёт очень конкретные формы объектов.
Пример
HTTPSuccess и HTTPError ограничивают status и statusText несколькими литеральными значениями каждый. Обычная проверка диапазона status не сужает объединение, поэтому в примере используется небольшой защитник типа isSuccess; внутри каждой ветки после этого можно безопасно использовать data или error.
HTTPSuccess (status: 200 | 201 | 204, statusText: "OK" | "Created" | "No Content", data: any) и HTTPError (status: 400 | 401 | 403 | 404 | 500, соответствующие литералы statusText, error: string), а также HTTPResponse как их объединение. Напишите защитник типа isSuccess (response is HTTPSuccess), который проверяет, что статус находится в диапазоне 200, и функцию handleResponse, которая с его помощью выводит Успех: <statusText> и данные при успехе, а иначе выводит Ошибка <status>: <statusText> и Сообщение: <error>. Вызовите её с ответом 200 "OK", содержащим { username: "john_doe", email: "john@example.com" }, и с ответом 404 "Not Found" с ошибкой Пользователь не найден в базе данных.
Вывод: программа на TypeScript и захваченный вывод
// Объект с литеральными значениями свойств
type HTTPSuccess = {
status: 200 | 201 | 204;
statusText: "OK" | "Created" | "No Content";
data: any;
};
type HTTPError = {
status: 400 | 401 | 403 | 404 | 500;
statusText: "Bad Request" | "Unauthorized" | "Forbidden" | "Not Found" | "Internal Server Error";
error: string;
};
type HTTPResponse = HTTPSuccess | HTTPError;
// Здесь нужен защитник типа: одна лишь проверка числового диапазона не сужает объединение
function isSuccess(response: HTTPResponse): response is HTTPSuccess {
return response.status >= 200 && response.status < 300;
}
function handleResponse(response: HTTPResponse) {
if (isSuccess(response)) {
console.log(`Успех: ${response.statusText}`);
console.log(response.data);
} else {
console.log(`Ошибка ${response.status}: ${response.statusText}`);
console.log(`Сообщение: ${response.error}`);
}
}
// Пример использования
const successResponse: HTTPSuccess = {
status: 200,
statusText: "OK",
data: { username: "john_doe", email: "john@example.com" }
};
const errorResponse: HTTPError = {
status: 404,
statusText: "Not Found",
error: "Пользователь не найден в базе данных"
};
handleResponse(successResponse);
handleResponse(errorResponse);
Шаблонные литеральные типы
Начиная с TypeScript 4.1 вы можете строить новые строковые литеральные типы, комбинируя существующие с помощью синтаксиса шаблонных строк — обратных кавычек и подстановок ${...}. Каждая комбинация значений из объединений-подстановок становится членом нового объединения.
Пример
Пример идёт от простого объединения DirectionAndDistance к более продвинутым применениям: Capitalize для построения имён событий, отображаемый тип, создающий имена геттеров, infer для извлечения имён параметров из строки маршрута, тип CssValue из числа и единицы измерения, а также типизированный построитель SQL-запросов. Файл заканчивается export {}, чтобы его локальный тип EventTarget не конфликтовал со встроенным типом DOM.
Direction и Distance в DirectionAndDistance (`${Direction}-${Distance}`) и присвойте переменной route допустимые маршруты, например "north-5km". Затем постройте EventName с помощью Capitalize, отображаемый тип GetterName<T>/UserGetters над типом User, рекурсивный ExtractRouteParams<T>, который извлекает имена :param из пути с помощью infer, тип CssValue вида `${number}${CssUnit}`, тип ApiUrl и функцию query<T extends Table>, возвращаемым типом которой является `SELECT * FROM ${T}${string}` и которая вызывается для таблицы users с WhereCondition. Недопустимые присваивания оставьте закомментированными. Ничего не выводится.
Добавьте export {}; в конце, чтобы локальный тип EventTarget не конфликтовал со встроенным глобальным типом DOM.
Вывод: программа на TypeScript и захваченный вывод
// Простые шаблонные литералы
type Direction = "north" | "south" | "east" | "west";
type Distance = "1km" | "5km" | "10km";
// Использование шаблонных литералов для их комбинирования
type DirectionAndDistance = `${Direction}-${Distance}`;
// "north-1km" | "north-5km" | "north-10km" | "south-1km" | ...
let route: DirectionAndDistance;
route = "north-5km"; // Допустимо
route = "west-10km"; // Допустимо
// route = "north-2km"; // Ошибка
// route = "5km-north"; // Ошибка
// Продвинутые преобразования строк
type EventType = "click" | "hover" | "scroll";
type EventTarget = "button" | "link" | "div";
type EventName = `on${Capitalize<EventType>}${Capitalize<EventTarget>}`;
// "onClickButton" | "onClickLink" | "onClickDiv" | ...
// Динамический доступ к свойствам
type User = {
id: number;
name: string;
email: string;
createdAt: Date;
};
type GetterName<T> = `get${Capitalize<string & keyof T>}`;
type UserGetters = {
[K in keyof User as GetterName<User>]: () => User[K];
};
// { getId: () => number; getName: () => string; ... }
// Сопоставление строковых шаблонов
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">; // "userId" | "postId"
// Единицы измерения и значения CSS
type CssUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw';
type CssValue = `${number}${CssUnit}`;
let width: CssValue = '100px'; // Допустимо
let height: CssValue = '50%'; // Допустимо
// let margin: CssValue = '10'; // Ошибка
// let padding: CssValue = '2ex'; // Ошибка
// Версионирование API
type ApiVersion = 'v1' | 'v2' | 'v3';
type Endpoint = 'users' | 'products' | 'orders';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiUrl = `https://api.example.com/${ApiVersion}/${Endpoint}`;
// Сложный пример: динамический построитель SQL-запросов
type Table = 'users' | 'products' | 'orders';
type Column<T extends Table> =
T extends 'users' ? 'id' | 'name' | 'email' | 'created_at' :
T extends 'products' ? 'id' | 'name' | 'price' | 'in_stock' :
T extends 'orders' ? 'id' | 'user_id' | 'total' | 'status' : never;
type WhereCondition<T extends Table> = {
[K in Column<T>]?: {
equals?: any;
notEquals?: any;
in?: any[];
};
};
function query<T extends Table>(
table: T,
where?: WhereCondition<T>
): `SELECT * FROM ${T}${string}` {
// В реализации здесь строился бы запрос
return `SELECT * FROM ${table}` as const;
}
// Использование
const userQuery = query('users', {
name: { equals: 'John' },
created_at: { in: ['2023-01-01', '2023-12-31'] }
});
// Тип: "SELECT * FROM users WHERE ..."
// Делаем этот файл модулем, чтобы локальный EventTarget не конфликтовал со встроенным глобальным
export {};
Рекомендуемые практики
Делайте так:
- Используйте литеральные типы для фиксированных наборов значений (перечислимые варианты, значения конфигурации)
- Комбинируйте их с типами-объединениями для большей типобезопасности
- Используйте шаблонные литеральные типы для сопоставления строковых шаблонов
- По возможности позволяйте выводу типов работать за вас
- Документируйте, что означает каждый литеральный тип
Не делайте так:
- Не злоупотребляйте литеральными типами там, где лучше подошёл бы более общий тип
- Не создавайте чрезмерно большие типы-объединения, которые замедляют проверку типов
- Не используйте строковые литералы там, где лучше подошло бы перечисление
Вопросы производительности
- Большие типы-объединения могут замедлять проверку типов
- Сложные шаблонные литеральные типы могут увеличивать время компиляции
- Используйте псевдонимы типов, чтобы давать имена сложным литеральным типам, вместо их повторения
- Помните об ограничениях глубины рекурсии в TypeScript для рекурсивных шаблонных литеральных типов