Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

TypeScript: просунуті типи

20 питань · ~20 хв · Версія v3.0

Увійдіть, щоб продовжити

Дженерики й обмеження, звуження й union-типи, утилітні, умовні й зіставлені типи, виведення типів і шаблонні літерали - питання від middle до senior.

За спробу
20
У пулі
55
Проходжень
0
Середній бал
-
Пройшли на 70%+
-

Питання для підготовки

39 питань

Для опису форми об'єкта обидва працюють майже однаково:

interface User {
  id: number;
  name: string;
}

type UserType = {
  id: number;
  name: string;
};

Відмінності:

  • type описує будь-що, не лише об'єкти: об'єднання (type Status = 'draft' | 'published'), кортежі, примітиви, умовні й зіставлені типи. interface - лише форму об'єкта (і функції, класу).
  • Злиття оголошень: два interface з однаковим іменем зливаються в один. Так розширюють чужі типи - наприклад, додають поле до Window. type з тим самим іменем - помилка.
  • Розширення: interface Admin extends User {} проти type Admin = User & { role: string }. При конфлікті полів extends одразу дає зрозумілу помилку, а перетин & може мовчки дати never.
  • Повідомлення про помилки з інтерфейсами інколи читабельніші, а великі ієрархії інтерфейсів компілятор перевіряє трохи швидше.

Практичне правило: interface - для форм об'єктів і публічних API (їх можна розширити), type - для об'єднань, кортежів і всього обчислюваного. Головне - послідовність у кодовій базі: обидва варіанти правильні.

Докладніше в документації: Аліаси типів та інтерфейси

Обидва означають «тут може бути будь-яке значення», але з протилежним ставленням до перевірок:

  • any вимикає перевірку типів. З ним можна робити що завгодно - викликати методи, звертатися до полів, передавати куди завгодно. Компілятор мовчить, а помилка вилізе під час виконання.
  • unknown - безпечний аналог: присвоїти в нього можна будь-що, але використати значення не можна, доки не доведете, що воно потрібного типу.
const a: any = JSON.parse(text);
a.user.name.toUpperCase();     // компілюється - і може впасти

const u: unknown = JSON.parse(text);
u.user;                        // помилка компіляції

if (typeof u === 'object' && u !== null && 'user' in u) {
  // тут TypeScript знає більше
}

Чому any шкідливий: він «заразний». Значення, отримане з any, теж any, і відсутність перевірок непомітно розповзається кодом.

Коли що:

  • unknown - для даних ззовні: JSON.parse, відповідь API, catch (error) (у строгому режимі error і так unknown), значення з localStorage. Звужуєте перевірками або схемою валідації (Zod).
  • any - як тимчасовий захід при міграції з JavaScript, з коментарем і планом прибрати.

У tsconfig варто тримати "strict": true (він вмикає noImplicitAny), а лінтер налаштувати так, щоб явний any був помітним.

Докладніше в документації: unknown

Generics - параметри типів: функція, клас чи тип працює з різними типами, зберігаючи зв'язок між ними. Без generics довелося б вибирати між дублюванням коду й any.

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const n = first([1, 2, 3]);        // number | undefined
const s = first(['a', 'b']);       // string | undefined

TypeScript сам вивів T з аргументу - писати first<number>(...) не потрібно.

Обмеження extends - вимога до параметра типу:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('abc', 'de');       // працює з рядками
longest([1, 2], [3]);       // і з масивами
longest(10, 20);            // помилка: у number немає length

keyof разом з generics - типобезпечний доступ до полів:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

pluck(users, 'email');      // string[]
pluck(users, 'emial');      // помилка компіляції: опечатка видна одразу

Де generics щодня: Array<T>, Promise<T>, Map<K, V>, хуки (useState<User | null>(null)), типізовані API-клієнти (get<User>('/users/1')).

Пастка: generic, який використовується лише раз (function log<T>(x: T): void), нічого не дає - тут достатньо unknown. Параметр типу має сенс, коли він пов'язує кілька місць: аргументи між собою або аргумент з результатом.

Докладніше в документації: Generics

Звуження (narrowing) - TypeScript аналізує перевірки в коді й усередині гілки вважає тип вужчим, ніж оголошений.

function format(value: string | number | null) {
  if (value === null) return '-';
  if (typeof value === 'number') {
    return value.toFixed(2);   // тут value: number
  }
  return value.trim();         // а тут value: string
}

Що звужує тип:

  • typeof x === 'string', x instanceof Date, Array.isArray(x);
  • перевірки на null/undefined і на істинність;
  • 'field' in obj - чи є властивість;
  • порівняння з літералом (status === 'paid') - основа discriminated unions;
  • ранній return чи throw: після них тип уже звужено до кінця функції.

Власний type guard - функція з типом повернення value is T:

interface Cat { meow(): void }
interface Dog { bark(): void }

function isCat(animal: Cat | Dog): animal is Cat {
  return 'meow' in animal;
}

if (isCat(pet)) pet.meow();

Assertion function - кидає виняток, якщо умова не виконана, і звужує тип після виклику:

function assertDefined<T>(value: T): asserts value is NonNullable<T> {
  if (value == null) throw new Error('Значення відсутнє');
}

Пастка: type guard - це обіцянка, яку компілятор не перевіряє. Якщо isCat повертає true для собаки, TypeScript повірить. Тому логіка в guard має бути простою й надійною, а для даних ззовні краще схема валідації.

Докладніше в документації: Звуження типів

Utility types - вбудовані перетворення типів, щоб не описувати схожі форми вручну:

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

type UserUpdate = Partial<Omit<User, 'id'>>;   // усі поля, крім id, необов'язкові
type PublicUser = Omit<User, 'password'>;
type UserPreview = Pick<User, 'id' | 'name'>;
type RolesMap = Record<'admin' | 'editor', string[]>;
type Frozen = Readonly<User>;

Ще корисні: Required, NonNullable, ReturnType<typeof fn>, Parameters<typeof fn>, Awaited<Promise<T>>.

Під капотом це mapped types - тип, що проходить по ключах іншого типу:

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Модифікатори можна і додавати, і знімати
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Перейменування ключів через as (TS 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<Pick<User, 'name'>>;   // { getName: () => string }

Навіщо на практиці: один джерельний тип (User), а похідні - форма оновлення, публічне представлення, стан форми - виводяться з нього. Додали поле в User - усі похідні оновилися, і компілятор покаже місця, які треба доробити.

Обережно: надто «розумні» типи важко читати й налагоджувати, а повідомлення про помилки в них стають незрозумілими. Якщо тип потрібно пояснювати, інколи простіше описати його явно.

Докладніше в документації: Utility Types

Прочитати - ще не значить знати

20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.