Поведения TypeScript, которые удивляют опытных разработчиков, и почему
Структурное типирование, проверки наличия свойств, использование ключевого слова as const, условные и отображенные типы, а также принципы проектирования, превращающие их в более безопасный код.
Большинство разработчиков сначала рассматривают TypeScript как JavaScript с аннотациями, но затем сталкиваются с поведением, которое не соответствует этому представлению: объект с дополнительными полями принимается в одном месте и отклоняется в другом, утверждение типа ничего не «преобразует», а ключевое слово readonly всё равно позволяет изменять вложенные значения. Под базовыми аннотациями скрывается язык на уровне типов с собственными правилами совместимости, вывода типов и вычислений. В этом руководстве поочередно объясняются эти неожиданности — от уничтожения типов во время выполнения и структурного типирования до использования функции infer, литеральных типов шаблонов и оператора satisfies — а затем они превращаются в практические принципы проектирования, которые можно применять в реальных кодовых базах.
Типы перестают существовать при выполнении кода
Вот интерфейс и объект, аннотированный им:
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
Может показаться, что запущенная программа знает, что user — это объект типа User. Но это не так. В процессе компиляции интерфейс удаляется, и в итоге остается примерно следующее:
const user = {
id: 1,
name: "Lakhveer"
};
Во время выполнения программы значение типа User отсутствует. TypeScript сначала выполняет статический анализ, затем генерирует JavaScript, и только этот JavaScript попадает в движок:
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Типы предназначены для информирования компилятора; они не являются объектами во время выполнения. Практический результат заключается в том, что TypeScript никогда не проверяет данные, поступающие извне программы. Приведенная ниже аннотация лишь утверждает то, что возвращает сервер; ничто не проверяет это:
const response: User = await fetch("/api/user")
.then(res => res.json());
Для внешних данных необходима проверка во время выполнения. Библиотеки для работы со схемами, такие как Zod, один раз описывают структуру данных и проверяют ее при их поступлении:
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
Полезный способ запомнить этот принцип: компилятор защищает ваш код, в то время как проверка во время выполнения защищает ваше приложение. Руководство блога по совместному использованию одной схемы Zod в React-фронтенде и Node-бэкенде показывает, как применять этот подход с обеих сторон.
any, unknown и бремя доказывания
any отключает проверку
При использовании any каждая из этих бессмысленных операций компилируется без ошибок:
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
any фактически просит компилятор доверять вам и прекратить проверку. Именно поэтому параметр, заданный в таком формате, молча игнорирует большинство возможностей TypeScript:
function processUser(user: any) {
console.log(user.name);
}
unknown требует доказательств
unknown также принимает любое значение:
let value: unknown = "hello";
Но прямое использование его приводит к ошибкам:
value.foo;
Сначала необходимо сузить тип, например до строки:
if (typeof value === "string") {
console.log(value.toUpperCase());
}
или до числа:
if (typeof value === "number") {
console.log(value.toFixed(2));
}
Разницу в подходах легко описать:
any
↓
"Trust me"
unknown
↓
"Prove it first"
Поэтому, когда вы действительно не знаете тип значения, используйте
unknown
вместо того чтобы
any
и позвольте компилятору заставить вас доказать, что у вас есть это значение перед его использованием.
Совместимость зависит от структуры, а не от имён
Разработчики, пришедшие из Java, C# или C++, часто удивляются тому, что такое присваивание допускается:
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
TypeScript использует структурное типирование: совместимость определяется свойствами значения, а не тем, как оно было объявлено. Для User достаточно только этого:
name: string
а у employee это свойство есть, плюс ещё несколько. Логика, которую применяет компилятор, выглядит следующим образом:
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
или, в виде потока принятия решений:
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
Чрезмерная проверка свойств применяется к новым литералам
А теперь неожиданность: попытка добавить дополнительное поле непосредственно в объектный литерал отклоняется:
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
со следующей ошибкой:
Object literal may only specify known properties
Однако при присваивании тех же данных через промежуточную переменную всё работает:
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
Причина в том, что TypeScript выполняет чрезмерную проверку свойств для новых объектных литералов, созданных непосредственно в момент присваивания, чтобы избежать опечаток. Эта проверка не связана с структурной совместимостью. Поэтому утверждение «TypeScript отклоняет дополнительные свойства» верно только для литералов; как только объект проходит через переменную, дополнительные поля допускаются.
Типы литералов и производные объединения
Точные значения вместо широких типов
Переменную можно ограничить конкретными значениями:
let direction: "left" | "right";
direction = "left";
Поэтому эта присваивание не срабатывает:
direction = "up";
В объявлении не указано
direction: string
Там сказано
direction must be EXACTLY:
"left"
OR
"right"
Что повышение точности значительно улучшает API. Функция развертывания может принимать только известные среды:
type Environment =
| "development"
| "staging"
| "production";
function deploy(env: Environment) {
// ...
}
И опечатка приводит к ошибке компиляции, а не к неудачному развертыванию:
deploy("testing");
Как as const влияет на вывод
Обычный литерал массива строк:
const colors = ["red", "blue", "green"];
выводится как
string[]
Добавление as const:
const colors = ["red", "blue", "green"] as const;
приводит к созданию только для чтения кортежа из литеральных типов:
readonly ["red", "blue", "green"]
Из этого кортежа можно получить объединение, используя индексацию с number:
type Color = typeof colors[number];
Что даёт:
type Color = "red" | "blue" | "green";
Это устраняет распространённое дублирование. Без этого необходимо вручную синхронизировать объединение и массив:
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
Благодаря этому массив становится единственным источником правды, а тип определяется автоматически:
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
Этот принцип обобщается: когда система типов может вывести информацию, не записывайте её дважды.
Ключевые слова, действующие на уровне типов
typeof выполняет две функции
В JavaScript,
typeof value
является оператором во время выполнения. Например,
typeof "hello";
возвращает
"string"
В контексте типов TypeScript повторно использует это ключевое слово для получения статического типа переменной:
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Здесь
User
становится
{
id: number;
name: string;
}
Одно и то же ключевое слово, два контекста:
Runtime:
typeof value
Type system:
typeof variable
keyof преобразует ключи в объединение
Учитывая интерфейс,
interface User {
id: number;
name: string;
email: string;
}
этот тип
type UserKeys = keyof User;
является
"id" | "name" | "email"
В сочетании с генериками это позволяет написать доступатор свойства, который принимает только реальные ключи:
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
Призыв с уже существующим ключом работает:
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
в то время как отсутствующий ключ отклоняется на этапе компиляции:
getValue(user, "salary");
Тип возвращаемого значения также точен: T[K] соответствует типу конкретного свойства.
Генерики связывают значения между собой
Типичный генерик просто возвращает то, что получил:
function identity<T>(value: T): T {
return value;
}
Генерики становятся более интересными, когда они связывают несколько значений вместе. Здесь оба аргумента должны иметь один и тот же тип:
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
поэтому такой призыв принимается:
pair(10, 20);
Но этот вариант не сработает, потому что T из первого аргумента интерпретируется как number, и к нему нельзя присвоить строку:
pair(10, "hello");
Генерики также могут прямо связывать входные и выходные данные, включая случай пустых значений:
function first<T>(items: T[]): T | undefined {
return items[0];
}
Для вызова вида
const numbers = first([1, 2, 3]);
компилятор сообщает о результате следующее:
number | undefined
Вычисление типов
Условные типы — это if на уровне типов
Условный тип выбирает между двумя результатами на основе проверки:
type IsString<T> =
T extends string
? true
: false;
Следовательно
type A = IsString<string>;
результатом становится
true
и
type B = IsString<number>;
результатом становится
false
Концептуально вы пишете код вручную, только он выполняется в компиляторе, а не в вашей программе:
if T is string
return true
else
return false
Функция infer извлекает части типа
Внутри условного типа infer вводит переменную типа, которую TypeScript заполняет путем сопоставления. Это переосуществляет встроенный ReturnType:
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
При применении к реальной функции он определяет тип возвращаемого объекта:
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
Ментальная модель здесь — это сопоставление шаблонов:
Function
↓
infer R
↓
Extract return type
Многие стандартные вспомогательные типы создаются именно так.
Типы-маппинги преобразуют каждое свойство
Начиная с интерфейса,
interface User {
id: number;
name: string;
email: string;
}
тип-маппинг итерируется по его ключам, чтобы создать версию с ограниченным доступом:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
или версию с возможностью отсутствия свойства:
type OptionalUser = {
[K in keyof User]?: User[K];
};
вместо того чтобы вручную переписывать каждое поле:
id?: number;
name?: string;
email?: string;
Вспомогательные типы создаются из этих элементов
TypeScript поставляет библиотеку таких вспомогательных функций:
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>
Если у нас есть модель вроде этой,
interface User {
id: number;
name: string;
email: string;
}
Pick сохраняет выбранные ключи:
type UserPreview = Pick<User, "id" | "name">;
производит
{
id: number;
name: string;
}
а Omit удаляет их:
type UserWithoutEmail = Omit<User, "email">;
Полную информацию смотрите в руководстве блога встроенных вспомогательных типов TypeScript.
never, narrowing и guards
never подтверждает, что вы учли всё
never — это тип значения, которое не может существовать, например результат функции, которая всегда выбрасывает исключение:
function fail(message: string): never {
throw new Error(message);
}
Его настоящая сила проявляется при проверках исчерпывающести. Возьмём союз статусов:
type Status =
| "loading"
| "success"
| "error";
и конструкцию switch, в которой ветка default передаёт значение функции, принимающей только never:
function handleStatus(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error("Unexpected value: " + value);
}
Когда обрабатываются все случаи, к моменту достижения блока default значение status уже сужено до never, поэтому происходит типовая проверка вызова. Теперь предположим, что кто-то расширяет этот союз:
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
Необработанный вариант "cancelled" попадает в функцию assertNever; его нельзя присвоить значению never, и компилятор указывает на все конструкции switch, которые требуют обновления.
Сужение значений следует за потоком управления
Компилятор отслеживает, как проверки влияют на возможные значения переменных:
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
В первом ветке,
value
известно, что
string
а во втором — это
number
Собственные типовые защитники
Вы можете научить компилятор распознавать собственные типы с помощью функции, возвращающей предикат типа, например value is User:
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
После успешной проверки,
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
компилятор рассматривает значение как
data: User
внутри блока. Следует помнить, что компилятор полностью доверяет предикату. Эта проверка лишь убеждается в том, что name существует, но не в том, что это строка; поэтому небрежная проверка фактически является непроверенным утверждением.
Моделирование состояний с дискриминированными союзами
Распространённый, но слабый подход заключается в размещении всех возможностей в одном объекте с необязательными полями:
interface State {
status: string;
data?: User;
error?: string;
}
Дискриминированный союз моделирует каждое состояние отдельно, помечая их с помощью status:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Переключение по метке ограничивает каждую ветвь именно теми полями, которые там существуют:
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
Это исключает противоречия, такие как
status = success
error = "Something went wrong"
что свободная версия с удовольствием позволяет. Основной принцип заключается в моделировании допустимых состояний, а не в разрешении недопустимых и проверке их повсюду.
проходит проверки без переопределения
satisfies проверяет, соответствует ли выражение определенному типу, сохраняя при этом собственный выводимый тип выражения:
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
Это идеально подходит для объектов конфигурации, когда необходима проверка, но в то же время нужно сохранить литеральные значения и точные ключи. Сравните с утверждением:
const config = {...} as Config;
as указывает компилятору рассматривать значение как данный тип, и он примет его на слово. satisfies же просит компилятор подтвердить соответствие. Когда ваша цель — проверка, а не переопределение механизма проверки, предпочтите
satisfies
вместо
as
Ограничения, которые ставят людей в тупик
readonly имеет ограниченную глубину
Рассмотрим тип с свойствами только для чтения, одно из которых является объектом:
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
Переопределение свойства верхнего уровня заблокировано:
user.name = "New Name";
но изменение поля внутри вложенного объекта всё ещё разрешено:
user.address.city = "Indore";
readonly применяется только к тому свойству, которое он помечает, и не рекурсивно. Для полной непрерывности данных требуется рекурсивный тип или механизм во время выполнения, причём стоит отметить, что Object.freeze также имеет ограниченную глубину.
Утверждения не преобразуют значения
Такое двойное утверждение компилируется:
const value = "123" as unknown as number;
но ничего не преобразуется. Во время выполнения,
typeof value
продолжает отображаться
string
Если вам нужно число, выполните прямое преобразование:
const value = Number("123");
Утверждения меняют только мнение компилятора, но не само значение.
Опциональное поле не всегда равно нedefинированному
Опциональное свойство:
interface User {
name?: string;
}
обычно означает, что ключ может отсутствовать, поэтому объект пустой
{}
является допустимым, так же как и
{
name: "Lakhveer"
}
Но то, допускается ли явное
{
name: undefined
}
указание, зависит от настроек. Включение
{
"exactOptionalPropertyTypes": true
}
позволяет компилятору различать эти два случая. Это важно для API, где
property missing
и
property explicitly undefined
означают разные вещи, например в запросе PATCH, когда отсутствие поля означает «оставить без изменений», а явное значение — «удалить его».
Индексация может скрыть нedefинированное значение
TypeScript намеренно не пытается предотвратить каждую ошибку во время выполнения. Чтение за пределы массива:
const numbers = [1, 2, 3];
const value = numbers[100];
при стандартных настройках обрабатывается так, как будто число всегда присутствует. Включение
{
"noUncheckedIndexedAccess": true
}
обеспечивает доступ
numbers[100]
отчитывается как
number | undefined
что вынуждает вас обрабатывать случай отсутствия данных.
Типы строк и реляционные типы
Типы шаблонных литералов
TypeScript может создавать типы строк из других типов строк:
type EventName =
`user:${"created" | "updated" | "deleted"}`;
что расширяется до
"user:created"
"user:updated"
"user:deleted"
Та же техника может использоваться для описания маршрутов:
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
поэтому допустимыми значениями являются
"GET /users"
"POST /users"
Сочетание элементов в вычисляемые API
Эти функции объединяются вместе:
keyof
typeof
conditional types
mapped types
template literals
infer
generics
Начните с словаря, связывающего имена событий с типами данных:
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
Затем универсальный эмиттер может связать каждое имя события с соответствующими данными с помощью keyof и индексированного доступа:
class EventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(
event: K,
callback: (payload: Events[K]) => void
) {
// ...
}
emit<K extends keyof Events>(
event: K,
payload: Events[K]
) {
// ...
}
}
Отправка известного события с правильными данными компилируется:
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
в то время как отправка неверных данных приводит к отклонению:
emitter.emit("userCreated", {
name: "Lakhveer"
});
Теперь компилятор понимает связь между именем события и данными, которые должны сопровождать его.
Режим строгости
Профессиональный проект обычно должен начинаться с:
{
"compilerOptions": {
"strict": true
}
}
Этот единственный флаг активирует набор проверок, включая:
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
Он также включает такие опции, как strictBindCallApply и noImplicitThis. Добавление мер безопасности только после появления ошибок гораздо дороже, чем позволить компилятору выступать в качестве первой линии защиты.
Сделать недопустимые состояния не представимыми
Рассмотрим жизненный цикл запроса, моделируемый как объединение:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Сравните его с дизайном на основе флагов и необязательных полей:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
Второй подход позволяет существованию нелогичных ситуаций, таких как
{
loading: true,
data: user,
error: "Something failed"
}
в то время как первый вариант делает создание таких комбинаций невозможным. Если есть какой-то принцип проектирования, который стоит заимствовать из TypeScript, то это именно он. Для более подробного рассмотрения см. моделирование доменов в TypeScript за пределами базовых аннотаций.
Принципы проектирования для продакшн-кода на TypeScript
Знание возможностей — это не то же самое, что умение хорошо проектировать с их использованием. Следующие принципы касаются того, как формировать системы.
Используйте any только в крайнем случае
Вместо того чтобы
function process(data: any) {
// ...
}
лучше использовать
function process(data: unknown) {
// validate/narrow first
}
а если вы знаете структуру, тем лучше
function process(data: User) {
// ...
}
Используйте any только тогда, когда вы точно понимаете, что отказываетесь от чего.
Позвольте системе самой определять очевидное
Подобные аннотации добавляют шум:
const name: string = "Lakhveer";
const age: number = 28;
Компилятор уже знает:
const name = "Lakhveer";
const age = 28;
Храните явные типы там, где они служат документацией контракта.
Пусть типы передают намерение
Простая строка мало что говорит:
function process(value: string) {}
Имя типа указывает на смысл значения:
type UserId = string;
function processUser(userId: UserId) {}
Одно ограничение: псевдоним вроде UserId = string документирует намерение, но не мешает передавать ProductId там, где ожидается UserId, поскольку оба представляют собой просто строки. Если их путаница представляет реальный риск, специализированный тип обеспечивает такой контроль.
Держите типы рядом с областью применения
Сигнатура, составленная из простых строк:
function createOrder(
userId: string,
productId: string,
status: string
) {}
становится гораздо понятнее с использованием типов области применения:
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Теперь компилятор понимает вашу бизнес-терминологию, а не только примитивные формы.
Предпочитайте союзы вместо булевых флагов
Независимые логические значения позволяют создавать невозможные комбинации:
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
Объединение позволяет иметь ровно одно состояние за раз:
type State =
| "loading"
| "success"
| "error";
Переходите к дискриминированному объединению, когда состоянию требуются собственные данные.
Проверка на границах системы
Компилятор не может гарантировать качество данных, поступающих извне:
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Рассматривайте все эти данные как ненадежные и направляйте их через единую обработочную цепь:
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
Пройдшие проверку данные становятся надежными типизированными данными, и только они доходят до логики приложения.
Избегайте чрезмерной сложности
Вы можете создавать чрезвычайно сложные типы, но сигнатура вроде
type Something<T, U, V, X extends ...> = ...
которую никто в команде не может объяснить через шесть месяцев, является техническим долгом. Типы должны делать кодовую базу понятнее, а не демонстрировать изобретательность.
Проектируйте API для правильного использования
Легко допустить ошибку при использовании нескольких флагов позиции:
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
Объект с указанными типами параметров сам по себе описателен и обеспечивает гораздо лучшее автодополнение:
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
Используйте комбинации вместо создания огромных интерфейсов
Один интерфейс с десятками полей
interface User {
// 50 properties
}
сложнее понимать, чем набор более мелких концепций, объединённых с помощью пересечений:
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
Включите компилятор в вашу стратегию тестирования
Типы не заменяют тесты, но позволяют устранить целые категории ошибок ещё до их обнаружения в ходе тестирования. При добавлении
type PaymentStatus =
| "pending"
| "paid"
| "failed";
нового элемента, такого как
"refunded"
это в сочетании с проверками полноты покрытия выявит все места, где его ещё не обработали.
Разделяйте в своем сознании время компиляции и выполнения
Спросите себя, в каком слое вы работаете. Это касается исключительно времени компиляции:
interface User {
id: number;
}
Это проверка во время выполнения:
if (typeof value === "object") {
}
А это проверка валидности внешних данных во время выполнения:
UserSchema.parse(data);
Прочитайте генерируемый JavaScript
Когда поведение кажется неясным, узнайте, в какой JavaScript код преобразуется. Знание обоих уровней помогает понять большинство неожиданностей.
Глубокое понимание JavaScript
TypeScript построен на JavaScript, поэтому основы остаются важными:
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Создавайте tsconfig.json осознанно
Не копируйте конфигурацию слепо. Поймите, какие преимущества и недостатки приносит каждый параметр:
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Каждый флаг влияет на баланс между безопасностью и удобством; например, параметр noImplicitOverride требует использования ключевого слова override для любого метода, заменяющего метод из базового класса.
Многоуровневая модель мышления
Полезно представить TypeScript как два параллельных слоя: JavaScript во время выполнения и систему типов во время компиляции, причем функции уровня типов дополняют друг друга:
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Если рассматривать его в этом свете, TypeScript перестаёт казаться просто набором синтаксических правил и становится языком для описания взаимосвязей между значениями: какие значения допускаются, как связаны объекты, какие состояния могут возникнуть, какие функции принимают и возвращают данные, а также какие случаи ещё не обработаны.
Основные выводы
Наиболее важными для освоения являются не самые заметные функции:
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
- Типы удаляются во время выполнения, поэтому внешние данные всегда требуют проверки.
- Совместимость обеспечивается на структурном уровне, при этом дополнительная проверка проводится только для новых объектных литералов.
as const, typeof, keyof, условные и отображаемые типы позволяют выводить типы вместо их дублирования.unknown вместо any и satisfies вместо as.never, чтобы компилятор мог определить, может ли тот или иной состояние действительно возникнуть.Цель не в создании самых сложных типов, а в коде, для которого компилятор отвечает на вопрос «может ли это состояние возникнуть?» ещё до запуска программы. Используйте TypeScript для разработки более безопасного кода, а не просто для описания уже написанного кода.
Связанные материалы
- Шесть техник TypeScript, превращающих типы в эффективные средства предотвращения ошибок — Узнайте, как функции satisfies, объединения с метками, правило never checks, тип unknown, производные типы и уникальные идентификаторы позволяют TypeScript обнаруживать настоящие ошибки на этапе компиляции, а не в рабочей среде.
- Моделирование доменов в TypeScript: за пределами базовых аннотаций типов — Ознакомьтесь с практическими подходами в TypeScript — от использования типов unknown и any до различных форм объединений и функции satisfies — которые помогают моделировать корректные состояния вместо простого маркирования данных.