Головна / Статті / Поведінки TypeScript, які дивують досвідчених розробників, та чому

Поведінки TypeScript, які дивують досвідчених розробників, та чому

Структурне типування, перевірки наявності властивостей, константи типу as const, умовні та маповані типи, а також принципи проектування, які перетворюють їх на безпечніший код.

4295 слів

Більшість розробників починають з того, що сприймають 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");

Як const змінює те, що інферується

Звичайний літеральний масив рядків:

const colors = ["red", "blue", "green"];

Інферується як

string[]

Додавання as const:

const colors = ["red", "blue", "green"] as const;

замість цього створює лише тупл з read-only типами літералів:

readonly ["red", "blue", "green"]

З цього тупла можна отримати union шляхом індексування за допомогою 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);
}

Коли оброблено всі варіанти, значення status до моменту досягнення блоку default вже звужено до 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");

Асерції змінюють те, що вважає компілятор, але ніколи не змінюють саме значення.

Опційний не завжди є тим самим, що й невизначений

Опційна властивість:

interface User {
  name?: string;
}

зазвичай означає, що ключ може бути відсутнім, тож об’єкт буде порожнім

{}

є допустимим, так само як і

{
  name: "Lakhveer"
}

Але те, чи дозволений явний

{
  name: undefined
}

варіант, залежить від налаштувань. Увімкнення

{
  "exactOptionalPropertyTypes": true
}

дозволяє компілятору розрізняти ці два варіанти. Це має значення для API, де

property missing

і

property explicitly undefined

означають різні речі, наприклад у запиті PATCH, де відсутнє поле означає „залишити без змін“, а явне значення — „очистити його“.

Індексування може приховати невизначене

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, які перетворюють помилки під час виконання на помилки компіляції — Дізнайтеся про десять технік TypeScript, від дискримінованих об’єднань та оператора satisfies до типів branded types та функцій infer, які дозволяють компілятору відхиляти недійсні стани ще до розгортання вашого коду.
  • Що ловить компілятор TypeScript, а що проходить повз у JavaScript — Порівняйте JavaScript та TypeScript поруч: інференція типів, анотації, примітиви, тип any, об’єднання та типові функції, а також що саме фіксує та виводить tsc.