Структурна типізація (утка типізація) в TypeScript
Структурна типізація в TypeScript перевіряє форму об'єкта (властивості та їхні типи), а не ім'я класу чи декларацію. Якщо об'єкт має все, що вимагає тип, він підходить.
Теорія
TL;DR
- Збіг форми важливіший за збіг імені. Об'єкт з
x: numberзадовольняєinterface HasX { x: number }без жодногоimplements - Зайві властивості в об'єкті переданому через змінну? Нормально. Зайві властивості в літералі об'єкта? Помилка
- TypeScript перевіряє структуру лише під час компіляції. Жодних витрат під час виконання
- Java/C# перевіряють імена, TypeScript перевіряє структуру. Ось і вся різниця
- Коли дві однакові форми представляють різні речі (валюти, ID), використовуй брендовані типи (branded types)
Швидкий приклад
interface HasX { x: number; }
const point = { x: 10, extra: "ignored" }; // без "implements HasX"
function useX(item: HasX): number {
return item.x * 2;
}
console.log(useX(point)); // 20 ✅ форма збігаєтьсяУ point є зайва властивість, але TypeScript перевіряє лише наявність x: number. Вона є, тому виклик компілюється. Зайва властивість просто ігнорується.
Головна різниця: форма vs. ім'я
У Java або C# два класи з однаковими методами залишаються різними типами, якщо один не оголошує implements або extends. TypeScript не дивиться на імена. Якщо потрібні властивості є з правильними типами, типи сумісні.
interface Cat { meow(): void; }
interface CatLike { meow(): void; }
const cat: Cat = { meow() {} };
const catLike: CatLike = cat; // ✅ однакова структура, сумісніУ номінальній системі це б не спрацювало. Cat і CatLike мають різні імена, тому вони різні типи незалежно від вмісту.
Коли використовувати
- Сторонні інтеграції: приймай будь-який об'єкт потрібної форми без обгорткових класів
- React props: будь-який компонент що передає правильну форму підходить, без явного наслідування
- Express middleware: власні розширення
reqприймаються, бо форма зростає структурно - Уникай коли форми мають залишатись семантично різними. Валюти, ID користувача, ключі сутностей: тут потрібні брендовані типи
Структурна vs. номінальна типізація
| Аспект | Структурна (TypeScript) | Номінальна (Java/C#) |
|---|---|---|
| Основа сумісності | Збіг форми | Ім'я + явна декларація |
| Зайві властивості | Допустимі через змінну | Не застосовно |
Ключове слово implements | Необов'язкове | Обов'язкове |
| Стиль помилок | Drift форми може накопичуватись непомітно | Явна невідповідність декларацій |
| Найкраще для | API що змінюються, бібліотеки | Суворі контракти, фінансові системи |
Перевірка надмірних властивостей
В структурній типізації є одне спеціальне правило. Літерали об'єктів при прямому присвоєнні типізованій змінній проходять перевірку надмірних властивостей (excess property checking). Передаєш той самий об'єкт через змінну і перевірка зникає.
interface Config { host: string; port: number; }
// ❌ прямий літерал - помилка зайвої властивості
const cfg: Config = { host: "localhost", port: 3000, debug: true };
// Error: 'debug' does not exist in type 'Config'
// ✅ через змінну - перевірки немає
const obj = { host: "localhost", port: 3000, debug: true };
const cfg2: Config = obj; // ПрацюєЦя асиметрія часто збиває з пантелику. Обидва випадки структурні, але перший отримує додаткову увагу, бо ти передаєш TypeScript свіжий об'єкт про який він знає все.
Як це обробляє компілятор
Компілятор TypeScript (tsc) виконує структурну перевірку при присвоєнні типів: кожна обов'язкова властивість цільового типу повинна бути в джерелі з сумісним типом. Жодних витрат часу виконання. Типи видаляються до запуску JavaScript, тому Node.js і браузер їх не бачать.
Екземпляри класів підпадають під те саме правило. Мають значення лише публічні члени:
class Animal { name: string; constructor(n: string) { this.name = n; } }
class Person { name: string; constructor(n: string) { this.name = n; } }
let a: Animal = new Person("Alice"); // ✅ однакова публічна формаДодай private члени і ситуація змінюється. Приватні поля кожного класу унікальні для нього. Два класи з приватними полями більше не є структурно сумісними навіть якщо виглядають ідентично.
Типові помилки
Помилка 1: Вважати що drift форми нешкідливий
interface Point { x: number; y: number; }
const p = { x: 1, y: 2, z: 3 };
const p2: Point = p; // ✅ компілюється
function move(pt: Point) {
// pt.z доступний під час виконання, але TypeScript нічого не знає про нього
console.log((pt as any).z); // типова безпека тут втрачається
}Присвоєння компілюється, але ти втрачаєш доступ до .z через тип. Виправлення: index signature або явне розширення інтерфейсу.
Помилка 2: Присвоєння вужчого типу ширшому
type A = { a: number };
type B = { a: number; b: string };
const narrow: A = { a: 1 };
const wide: B = narrow; // ❌ Відсутня властивість 'b'У A немає b, тому він не може задовольнити B. Зворотне завжди працює: B підходить туди де очікується A.
Помилка 3: Приватні члени блокують структурну сумісність
class Dog {
private breed: string;
constructor(b: string) { this.breed = b; }
bark() {}
}
class Cat {
private breed: string;
constructor(b: string) { this.breed = b; }
bark() {}
}
const d: Dog = new Cat("Persian"); // ❌ невідповідність приватних полівПоля private breed належать різним класам. TypeScript блокує це навіть якщо класи виглядають ідентично.
Помилка 4: Покладатись на структурну типізацію для семантичної безпеки
type USD = number;
type EUR = number;
function convertToEUR(amount: USD): EUR {
return amount * 0.85;
}
const euros: EUR = 100;
convertToEUR(euros); // ✅ немає помилки, але логічно неправильноЯ бачив цей патерн у фінансовому коді, де два числових аліаси приймали одне одного місяцями поки хтось не помітив. Обидва типи є просто number, тому TypeScript не може їх розрізнити. Брендовані типи вирішують це.
Де зустрічається в реальних проектах
- React: props-інтерфейси приймають будь-який об'єкт з правильною формою, явний клас не потрібен
- Express: middleware отримує
(req: Request, res: Response); власні властивостіreqпідтримуються якщо розширюєш тип структурно - Redux: action creators повертають
{ type: string; payload?: any }; будь-який відповідний об'єкт можна передати доdispatch - Node.js: stream-подібні об'єкти підключаються до API без формального
implements
Питання на співбесіді
Q: Що таке перевірка надмірних властивостей і коли вона спрацьовує?
A: Спрацьовує при прямому присвоєнні літерала об'єкта типізованій змінній або при передачі як прямого аргументу. Передача через змінну пропускає перевірку, бо TypeScript розглядає об'єкт як вже існуючий.
Q: Чи можуть два класи бути структурно несумісними навіть з однаковими методами?
A: Так. Приватні або захищені члени роблять класи несумісними. Кожен клас є власником своїх приватних членів, і TypeScript розглядає їх як різні навіть якщо назви полів збігаються.
Q: Як примусово використовувати номінальну типізацію в TypeScript?
A: Брендуй тип унікальним символом: type USD = number & { readonly __brand: unique symbol }. Це додає фантомну властивість яка існує лише на рівні типів і блокує випадкові присвоєння.
Q: Чому присвоєння свіжого літерала до супертипу дає помилку?
A: TypeScript застосовує перевірку надмірних властивостей до свіжих літералів. Якщо цільовий тип не оголошує зайву властивість, TypeScript дає помилку. Присвой спочатку до змінної і перевірка пропускається.
Q: Чи є витрати часу виконання від структурної типізації?
A: Жодних. Вся типова інформація видаляється до запуску JavaScript. Структурна перевірка відбувається виключно під час компіляції.
Q: (Senior) Тип функції (e: string) => void. Чи можна присвоїти (e: string, n: number) => void?
A: Ні. Параметри функцій перевіряються контраваріантно. Зайвий параметр n робить сигнатури несумісними, бо той хто викликає функцію не надає його.
Приклади
Базовий: збіг форми об'єкта
interface User {
id: number;
name: string;
}
// зайва властивість 'role' - без implements, без класу
const admin = { id: 1, name: "Alice", role: "admin" };
function greet(user: User): string {
return `Привіт, ${user.name}`;
}
console.log(greet(admin)); // "Привіт, Alice" ✅У admin є зайва властивість role, але User вимагає лише id і name. TypeScript бачить обидві, тому виклик допустимий. Властивість role просто поза зором типу.
Середній рівень: props React-компонента
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
className?: string;
}
const Button = ({ children, onClick, className }: ButtonProps) => (
<button onClick={onClick} className={className}>
{children}
</button>
);
const handleClick = () => console.log("clicked");
const usage = (
<Button onClick={handleClick} className="btn-primary">
Submit
</Button>
); // ✅ форма збігається з ButtonPropsButtonProps не цікавить звідки береться handleClick. Перевіряється лише форма сигнатури функції. Структурна типізація в реальному продакшн-коді.
Просунутий рівень: брендовані типи для номінальної поведінки
Коли дві форми однакові але повинні залишатись окремими, брендовані типи (branded types) додають маркер на рівні типів:
// прості аліаси - структурно ідентичні, TypeScript не може їх розрізнити
type USD = number;
type EUR = number;
// брендовані аліаси - структурно різні на рівні типів
type BrandedUSD = number & { readonly __brand: unique symbol };
type BrandedEUR = number & { readonly __brand: unique symbol };
// функції-конструктори як єдиний спосіб створити значення
function makeUSD(n: number): BrandedUSD { return n as BrandedUSD; }
function makeEUR(n: number): BrandedEUR { return n as BrandedEUR; }
function convertToEUR(amount: BrandedUSD): BrandedEUR {
return (amount * 0.85) as BrandedEUR;
}
const dollars = makeUSD(100);
convertToEUR(dollars); // ✅
convertToEUR(makeEUR(100)); // ❌ BrandedEUR не можна присвоїти BrandedUSDВластивість __brand ніколи не існує під час виконання. Це фантомне поле, яке TypeScript використовує лише під час перевірки типів. Це стандартний спосіб отримати номінальну поведінку від структурної системи типів.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.