Привет, я Богдан Новотарский!
Когда я начал использовать TypeScript несколько лет назад, мне казалось, что это панацея от большинства проблем JavaScript-разработки. Строгая типизация, интеллектуальные подсказки IDE и отлов ошибок на этапе компиляции — что может пойти не так? Как оказалось, довольно многое. Сегодня я хочу поделиться теми аспектами TypeScript, которые вызвали у меня больше всего головной боли и о которых почему-то стыдливо умалчивает официальная документация.
За время работы над десятками проектов разной сложности я сталкивался с такими особенностями TypeScript, которые заставляли меня часами гуглить решения и пересматривать архитектурные решения. Многое из этого нельзя найти в руководствах для начинающих или даже в продвинутых туториалах, но знание этих нюансов критически важно для продуктивной работы.
Типизация третьесторонних библиотек: ад декларационных файлов
Помню свой первый крупный проект на TypeScript, когда я с энтузиазмом начал интегрировать различные npm-пакеты, только чтобы обнаружить, что половина из них не имеет нормальных типов. В официальной документации это выглядит просто: установите @types/package-name
и все заработает. Но реальность оказалась куда сложнее.
// Выглядит невинно, правда?
import somePackage from 'some-package';
// А вот что получаем в реальности
const result = somePackage.someMethod();
// Error: Property 'someMethod' does not exist on type 'typeof import("some-package")'
Первая проблема: многие пакеты @types
безнадежно устарели или содержат критические ошибки. Я потратил три дня, пытаясь заставить работать типы для популярной библиотеки визуализации данных, прежде чем обнаружил в GitHub issue, что официальные типы не обновлялись больше года.
Вторая проблема: объявления типов иногда неправильно описывают реальное поведение библиотеки. Вот реальный случай из моей практики:
// В декларационном файле:
export function processData(data: string): ProcessedData;
// Фактическое поведение библиотеки:
// Функция также принимает массивы и объекты, но типы это запрещают
const result = processData(['item1', 'item2']); // Error в TypeScript, но работает в JavaScript
Что делать? Создавать свои декларационные файлы. Вот шаблон, который я использую:
// my-declarations.d.ts
declare module 'problematic-package' {
export function troublesomeFunction(data: any): any;
export class ProblematicClass {
constructor(options?: Record<string, unknown>);
doSomething(arg: unknown): unknown;
}
// И так далее...
}
Обратите внимание, что я часто начинаю с any
и unknown
, а затем постепенно уточняю типы по мере понимания реального API библиотеки. Это не идеально с точки зрения типобезопасности, но гораздо практичнее, чем бороться с неправильными типами.
Странности с наследованием интерфейсов и объединением типов
В теории TypeScript позволяет создавать сложные иерархии типов с помощью наследования и объединения. На практике это часто приводит к неочевидным результатам. Я, Богдан Новотарский, попадал в эту ловушку уже не один раз.
Рассмотрим простой пример:
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
}
interface Cat extends Animal {
color: string;
}
type Pet = Dog | Cat;
function getPetInfo(pet: Pet): string {
// Следующая строка вызовет ошибку компиляции:
return `${pet.name} is ${pet.age} years old`;
// Error: Property 'name' does not exist on type 'Pet'.
// Property 'name' does not exist on type 'Dog & Cat'.
}
Что? Как свойство name
может не существовать, если оно есть и у Dog
, и у Cat
? Проблема в том, как TypeScript обрабатывает объединенные типы и доступ к свойствам. Компилятор не уверен, какой конкретно тип будет передан, поэтому перестраховывается.
Решение:
function getPetInfo(pet: Pet): string {
// Вариант 1: проверка типа
if ('breed' in pet) {
return `${pet.name} is a ${pet.breed} dog`;
} else {
return `${pet.name} is a ${pet.color} cat`;
}
// Вариант 2: приведение типа (менее безопасно)
return `${(pet as Animal).name} is ${(pet as Animal).age} years old`;
}
Еще более подлый случай — пересечение типов с несовместимыми свойствами:
type A = { value: string };
type B = { value: number };
// Что вообще означает этот тип? Строка или число?
type C = A & B;
// Пытаемся использовать:
const c: C = { value: "hello" }; // Error
const c2: C = { value: 42 }; // Error
Тип C
на самом деле представляет невозможный объект, который одновременно должен иметь свойство value
типа string и number. Это never
тип в маскировке, и вы не сможете создать такой объект.
Утиная типизация и структурное сравнение типов — не то, чем кажутся
Одна из интересных особенностей TypeScript — его структурная система типов. В отличие от языков вроде Java, TypeScript сравнивает типы по их структуре, а не по имени. Это называют "утиной типизацией" (если что-то выглядит как утка и крякает как утка, значит это утка).
Звучит удобно, но создает проблемы, которые сложно отловить:
interface User {
id: number;
name: string;
}
interface Product {
id: number;
name: string;
price: number;
}
function processUser(user: User) {
console.log(`Processing user ${user.name}`);
}
const product: Product = {
id: 1,
name: "Laptop",
price: 1000
};
// Это скомпилируется без ошибок!
processUser(product);
TypeScript считает, что Product
совместим с User
, потому что содержит все требуемые поля. Это может приводить к неочевидным багам, особенно в больших кодовых базах.
Для решения я использую несколько подходов:
- Брендированные типы:
interface User {
__type: 'user'; // Служебное поле для различения типов
id: number;
name: string;
}
interface Product {
__type: 'product';
id: number;
name: string;
price: number;
}
// Теперь это вызовет ошибку типизации
processUser(product);
- Классы вместо интерфейсов, когда требуется номинальная типизация:
class User {
constructor(public id: number, public name: string) {}
}
class Product {
constructor(public id: number, public name: string, public price: number) {}
}
function processUser(user: User) {
console.log(`Processing user ${user.name}`);
}
const product = new Product(1, "Laptop", 1000);
// Теперь это вызовет ошибку
processUser(product);
Дженерики и условные типы: мощь, граничащая с безумием
Система типов TypeScript настолько мощная, что позволяет создавать абстракции, граничащие с функциональным программированием. Но с большой силой приходит и большая ответственность.
Вот простой пример условного типа:
type IsString<T> = T extends string ? true : false;
// Все работает ожидаемо
type Result1 = IsString<"hello">; // true
type Result2 = IsString<42>; // false
Теперь пример посложнее:
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;
type Test1 = Unpacked<string[]>; // string
type Test2 = Unpacked<() => string>; // string
type Test3 = Unpacked<Promise<number>>; // number
type Test4 = Unpacked<string | number[]>; // string | number ??
Что произойдет в Test4
? Интуитивно ожидаешь, что результатом будет string | number
, но на самом деле это не так просто. TypeScript применяет условный тип к каждому члену объединения, и результат может быть неожиданным.
А вот еще один случай:
type FilterType<T, U> = T extends U ? T : never;
type Result = FilterType<string | number | boolean, string | number>;
// Ожидаем: string | number
// Получаем: string | number
Здесь всё работает как ожидалось, но теперь представьте сложный пример с вложенными условными типами и дженериками. Я однажды потратил целый день, дебажа типы в сложном проекте, только чтобы понять, что неправильно понимал, как работает распределение условных типов.
Как я решаю эти проблемы? Декомпозиция. Я разбиваю сложные типы на более простые и использую промежуточные определения:
// Вместо одного монстра типизации
type ComplexType<T, U, V> = /* очень сложное выражение */;
// Делаю несколько простых шагов
type Step1<T> = /* простое преобразование */;
type Step2<T, U> = /* следующее преобразование */;
type Step3<T, V> = /* финальное преобразование */;
type ComplexType<T, U, V> = Step3<Step2<Step1<T>, U>, V>;
Это не только облегчает отладку, но и делает код более понятным для других разработчиков.
Производительность компилятора TypeScript: неожиданный враг
О чем точно не пишут в документации, так это о проблемах производительности. Когда ваш проект вырастает до сотен файлов, компиляция может превратиться в пытку. И дело не только во времени сборки, но и в том, как TypeScript влияет на производительность IDE.
На одном проекте я столкнулся с тем, что VS Code начал тормозить до неюзабельного состояния. Причина оказалась в сложных индексных типах:
// Это выглядит безобидно
type Keys = keyof SomeHugeInterface;
// А вот так мы использовали этот тип
type Values = {
[K in Keys]: SomeHugeInterface[K];
}
Когда SomeHugeInterface
содержит сотни свойств, такие операции с типами могут создать огромную нагрузку на типизатор.
Вот мои рекомендации по производительности:
- Разделяйте крупные интерфейсы на логические части
- Используйте
Pick
иOmit
для работы только с нужными свойствами - Не злоупотребляйте сложными условными типами
- Включайте инкрементальную компиляцию
- Настройте
tsconfig.json
для многопроектных сборок
// tsconfig.json для оптимизации производительности
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./tsbuildinfo",
"composite": true,
"skipLibCheck": true,
"isolatedModules": true
}
}
Странности с импортом и экспортом модулей
TypeScript идеально работает... пока не начинаешь смешивать разные форматы модулей. Комбинация CommonJS и ES модулей может привести к неожиданным проблемам:
// file1.ts (использует ES модули)
export const value = 42;
// file2.js (CommonJS)
module.exports = {
otherValue: 'hello'
};
// main.ts
import { value } from './file1';
import { otherValue } from './file2'; // Не сработает как ожидается
Проблема становится еще сложнее с динамическими импортами и экспортами по умолчанию. Я потратил бессчетное количество часов, разбираясь с ошибками вроде:
Error: Module '"/path/to/module"' has no exported member 'SomeExport'.
...когда я точно знал, что этот экспорт существует.
Моё решение — стандартизация формата модулей в проекте:
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
И использование консистентного синтаксиса импорта:
// Для библиотек с экспортом по умолчанию
import React from 'react';
// Для именованных экспортов
import { useState, useEffect } from 'react';
// Для смешанных случаев
import Express, { Request, Response } from 'express';
Мутации объектов и деструктивное присваивание — тихие убийцы типизации
TypeScript отлично защищает от многих ошибок, но есть ситуации, когда он беспомощно разводит руками. Одна из них — мутация объектов.
interface User {
readonly id: number;
name: string;
}
const user: User = {
id: 1,
name: "John"
};
// TypeScript предотвратит прямую мутацию
user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
// Но не защитит от этого:
const userCopy = user as any;
userCopy.id = 2;
console.log(user.id); // 2 - мутировали объект в обход системы типов
Еще одна проблема возникает при деструктуризации:
interface Options {
debug?: boolean;
timeout: number;
retries?: number;
}
function processWithOptions(options: Options) {
// Деструктуризация с дефолтными значениями
const { debug = false, timeout, retries = 3 } = options;
// TypeScript не отслеживает, что debug и retries здесь
// всегда определены из-за дефолтных значений
if (debug === undefined) {
// Этот код никогда не выполнится, но TypeScript этого не знает
console.log("Debug is undefined");
}
}
Мое решение — использовать вспомогательные функции для клонирования и трансформации объектов:
function safeClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
function withDefaults<T extends object, D extends Partial<T>>(
obj: T,
defaults: D
): T & Required<Pick<D, keyof D>> {
return { ...defaults, ...obj };
}
// Использование:
const options = withDefaults({ timeout: 1000 }, { debug: false, retries: 3 });
// Теперь options.debug и options.retries гарантированно определены
Библиотека объявлений DOM: главный источник боли
Если вы когда-нибудь работали с DOM в TypeScript, то знаете, насколько неточными могут быть типы для веб-API. Я, Богдан Новотарский, лично сталкивался с этим десятки раз.
// Выглядит безобидно:
document.getElementById('myElement').style.display = 'none';
// На самом деле компилятор будет ругаться:
// Object is possibly 'null'.
Да, DOM-методы могут возвращать null, и TypeScript правильно это обрабатывает. Но постоянные проверки на null становятся утомительными:
const element = document.getElementById('myElement');
if (element) {
element.style.display = 'none';
}
Распространенное "решение" — использование non-null assertion оператора:
document.getElementById('myElement')!.style.display = 'none';
Но это просто говорит TypeScript "доверься мне, я знаю, что делаю", и может привести к runtime-ошибкам.
Я предпочитаю использовать вспомогательные функции:
function getElementOrThrow<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Element with id ${id} not found`);
}
return element as T;
}
// Использование:
getElementOrThrow<HTMLDivElement>('myElement').style.display = 'none';
Это безопаснее, чем просто отключать проверки типов, и позволяет централизованно обрабатывать ошибки.
Тип any: необходимое зло, с которым нужно уметь работать
Документация TypeScript рекомендует избегать типа any
, и это хороший совет... в теории. На практике иногда без any
не обойтись, особенно при работе с динамическими данными или API.
// Что-то получаем от внешнего API
const data: any = fetchDataFromExternalAPI();
// Пытаемся использовать
console.log(data.user.profile.name); // Потенциальная бомба замедленного действия
Вместо прямого использования any
, я предпочитаю постепенно "укрощать" данные:
// Определяем ожидаемую структуру
interface UserProfile {
name: string;
email: string;
// другие поля...
}
interface User {
profile: UserProfile;
// другие поля...
}
interface ApiResponse {
user?: User;
// другие поля...
}
// Получаем данные
const rawData: unknown = fetchDataFromExternalAPI();
// Проверяем и конвертируем
function isApiResponse(data: unknown): data is ApiResponse {
if (typeof data !== 'object' || data === null) return false;
const response = data as Record<string, unknown>;
if (response.user !== undefined) {
if (typeof response.user !== 'object' || response.user === null) return false;
// Проверяем дальше...
}
return true;
}
if (isApiResponse(rawData)) {
// Теперь можем безопасно использовать
const userName = rawData.user?.profile?.name;
}
Да, это многословно, но защищает от множества потенциальных ошибок.
Заключение: TypeScript — прекрасен, но не идеален
После всего вышесказанного может показаться, что я не люблю TypeScript. Совсем наоборот! Я использую его в каждом проекте и считаю, что преимущества значительно перевешивают недостатки. Но осознание ограничений и подводных камней делает нас более эффективными разработчиками.
Главные советы, которые я могу дать на основе своего опыта:
- Не полагайтесь только на статическую типизацию — добавляйте runtime-проверки для критических данных
- Инвестируйте время в понимание сложных механизмов типизации, таких как условные типы и дженерики
- Создавайте абстракции, которые инкапсулируют сложную логику типов
- Не бойтесь использовать
any
илиunknown
, но делайте это осознанно - Тестируйте, тестируйте и еще раз тестируйте — TypeScript не заменяет модульные и интеграционные тесты
TypeScript продолжает развиваться, и многие проблемы, которые я описал, могут быть решены в будущих версиях. Но понимание текущих ограничений поможет вам избежать многих часов отладки и разочарования.
Автор: Богдан Новотарский – разработчик, изучающий Fullstack-разработку и делящийся своим опытом в IT.
Следите за новыми статьями:
GitHub: https://github.com/bogdan-novotarskij
Twitter: https://x.com/novotarskijb
Top comments (0)