DEV Community

Cover image for Подводные камни TypeScript, о которых не пишут в документации

Подводные камни TypeScript, о которых не пишут в документации

Привет, я Богдан Новотарский!

Когда я начал использовать TypeScript несколько лет назад, мне казалось, что это панацея от большинства проблем JavaScript-разработки. Строгая типизация, интеллектуальные подсказки IDE и отлов ошибок на этапе компиляции — что может пойти не так? Как оказалось, довольно многое. Сегодня я хочу поделиться теми аспектами TypeScript, которые вызвали у меня больше всего головной боли и о которых почему-то стыдливо умалчивает официальная документация.

За время работы над десятками проектов разной сложности я сталкивался с такими особенностями 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")'
Enter fullscreen mode Exit fullscreen mode

Первая проблема: многие пакеты @types безнадежно устарели или содержат критические ошибки. Я потратил три дня, пытаясь заставить работать типы для популярной библиотеки визуализации данных, прежде чем обнаружил в GitHub issue, что официальные типы не обновлялись больше года.

Вторая проблема: объявления типов иногда неправильно описывают реальное поведение библиотеки. Вот реальный случай из моей практики:

// В декларационном файле:
export function processData(data: string): ProcessedData;

// Фактическое поведение библиотеки:
// Функция также принимает массивы и объекты, но типы это запрещают
const result = processData(['item1', 'item2']); // Error в TypeScript, но работает в JavaScript
Enter fullscreen mode Exit fullscreen mode

Что делать? Создавать свои декларационные файлы. Вот шаблон, который я использую:

// 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;
  }

  // И так далее...
}
Enter fullscreen mode Exit fullscreen mode

Обратите внимание, что я часто начинаю с 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'.
}
Enter fullscreen mode Exit fullscreen mode

Что? Как свойство 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`;
}
Enter fullscreen mode Exit fullscreen mode

Еще более подлый случай — пересечение типов с несовместимыми свойствами:

type A = { value: string };
type B = { value: number };

// Что вообще означает этот тип? Строка или число?
type C = A & B;

// Пытаемся использовать:
const c: C = { value: "hello" }; // Error
const c2: C = { value: 42 }; // Error
Enter fullscreen mode Exit fullscreen mode

Тип 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);
Enter fullscreen mode Exit fullscreen mode

TypeScript считает, что Product совместим с User, потому что содержит все требуемые поля. Это может приводить к неочевидным багам, особенно в больших кодовых базах.

Для решения я использую несколько подходов:

  1. Брендированные типы:
interface User {
  __type: 'user'; // Служебное поле для различения типов
  id: number;
  name: string;
}

interface Product {
  __type: 'product';
  id: number;
  name: string;
  price: number;
}

// Теперь это вызовет ошибку типизации
processUser(product);
Enter fullscreen mode Exit fullscreen mode
  1. Классы вместо интерфейсов, когда требуется номинальная типизация:
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);
Enter fullscreen mode Exit fullscreen mode

Дженерики и условные типы: мощь, граничащая с безумием

Система типов TypeScript настолько мощная, что позволяет создавать абстракции, граничащие с функциональным программированием. Но с большой силой приходит и большая ответственность.

Вот простой пример условного типа:

type IsString<T> = T extends string ? true : false;

// Все работает ожидаемо
type Result1 = IsString<"hello">; // true
type Result2 = IsString<42>; // false
Enter fullscreen mode Exit fullscreen mode

Теперь пример посложнее:

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 ??
Enter fullscreen mode Exit fullscreen mode

Что произойдет в 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
Enter fullscreen mode Exit fullscreen mode

Здесь всё работает как ожидалось, но теперь представьте сложный пример с вложенными условными типами и дженериками. Я однажды потратил целый день, дебажа типы в сложном проекте, только чтобы понять, что неправильно понимал, как работает распределение условных типов.

Как я решаю эти проблемы? Декомпозиция. Я разбиваю сложные типы на более простые и использую промежуточные определения:

// Вместо одного монстра типизации
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>;
Enter fullscreen mode Exit fullscreen mode

Это не только облегчает отладку, но и делает код более понятным для других разработчиков.

Производительность компилятора TypeScript: неожиданный враг

О чем точно не пишут в документации, так это о проблемах производительности. Когда ваш проект вырастает до сотен файлов, компиляция может превратиться в пытку. И дело не только во времени сборки, но и в том, как TypeScript влияет на производительность IDE.

На одном проекте я столкнулся с тем, что VS Code начал тормозить до неюзабельного состояния. Причина оказалась в сложных индексных типах:

// Это выглядит безобидно
type Keys = keyof SomeHugeInterface;

// А вот так мы использовали этот тип
type Values = {
  [K in Keys]: SomeHugeInterface[K];
}
Enter fullscreen mode Exit fullscreen mode

Когда SomeHugeInterface содержит сотни свойств, такие операции с типами могут создать огромную нагрузку на типизатор.

Вот мои рекомендации по производительности:

  1. Разделяйте крупные интерфейсы на логические части
  2. Используйте Pick и Omit для работы только с нужными свойствами
  3. Не злоупотребляйте сложными условными типами
  4. Включайте инкрементальную компиляцию
  5. Настройте tsconfig.json для многопроектных сборок
// tsconfig.json для оптимизации производительности
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./tsbuildinfo",
    "composite": true,
    "skipLibCheck": true,
    "isolatedModules": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Странности с импортом и экспортом модулей

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'; // Не сработает как ожидается
Enter fullscreen mode Exit fullscreen mode

Проблема становится еще сложнее с динамическими импортами и экспортами по умолчанию. Я потратил бессчетное количество часов, разбираясь с ошибками вроде:

Error: Module '"/path/to/module"' has no exported member 'SomeExport'.
Enter fullscreen mode Exit fullscreen mode

...когда я точно знал, что этот экспорт существует.

Моё решение — стандартизация формата модулей в проекте:

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}
Enter fullscreen mode Exit fullscreen mode

И использование консистентного синтаксиса импорта:

// Для библиотек с экспортом по умолчанию
import React from 'react';

// Для именованных экспортов
import { useState, useEffect } from 'react';

// Для смешанных случаев
import Express, { Request, Response } from 'express';
Enter fullscreen mode Exit fullscreen mode

Мутации объектов и деструктивное присваивание — тихие убийцы типизации

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 - мутировали объект в обход системы типов
Enter fullscreen mode Exit fullscreen mode

Еще одна проблема возникает при деструктуризации:

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

Мое решение — использовать вспомогательные функции для клонирования и трансформации объектов:

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 гарантированно определены
Enter fullscreen mode Exit fullscreen mode

Библиотека объявлений DOM: главный источник боли

Если вы когда-нибудь работали с DOM в TypeScript, то знаете, насколько неточными могут быть типы для веб-API. Я, Богдан Новотарский, лично сталкивался с этим десятки раз.

// Выглядит безобидно:
document.getElementById('myElement').style.display = 'none';

// На самом деле компилятор будет ругаться:
// Object is possibly 'null'.
Enter fullscreen mode Exit fullscreen mode

Да, DOM-методы могут возвращать null, и TypeScript правильно это обрабатывает. Но постоянные проверки на null становятся утомительными:

const element = document.getElementById('myElement');
if (element) {
  element.style.display = 'none';
}
Enter fullscreen mode Exit fullscreen mode

Распространенное "решение" — использование non-null assertion оператора:

document.getElementById('myElement')!.style.display = 'none';
Enter fullscreen mode Exit fullscreen mode

Но это просто говорит 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';
Enter fullscreen mode Exit fullscreen mode

Это безопаснее, чем просто отключать проверки типов, и позволяет централизованно обрабатывать ошибки.

Тип any: необходимое зло, с которым нужно уметь работать

Документация TypeScript рекомендует избегать типа any, и это хороший совет... в теории. На практике иногда без any не обойтись, особенно при работе с динамическими данными или API.

// Что-то получаем от внешнего API
const data: any = fetchDataFromExternalAPI();

// Пытаемся использовать
console.log(data.user.profile.name); // Потенциальная бомба замедленного действия
Enter fullscreen mode Exit fullscreen mode

Вместо прямого использования 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;
}
Enter fullscreen mode Exit fullscreen mode

Да, это многословно, но защищает от множества потенциальных ошибок.

Заключение: TypeScript — прекрасен, но не идеален

После всего вышесказанного может показаться, что я не люблю TypeScript. Совсем наоборот! Я использую его в каждом проекте и считаю, что преимущества значительно перевешивают недостатки. Но осознание ограничений и подводных камней делает нас более эффективными разработчиками.

Главные советы, которые я могу дать на основе своего опыта:

  1. Не полагайтесь только на статическую типизацию — добавляйте runtime-проверки для критических данных
  2. Инвестируйте время в понимание сложных механизмов типизации, таких как условные типы и дженерики
  3. Создавайте абстракции, которые инкапсулируют сложную логику типов
  4. Не бойтесь использовать any или unknown, но делайте это осознанно
  5. Тестируйте, тестируйте и еще раз тестируйте — TypeScript не заменяет модульные и интеграционные тесты

TypeScript продолжает развиваться, и многие проблемы, которые я описал, могут быть решены в будущих версиях. Но понимание текущих ограничений поможет вам избежать многих часов отладки и разочарования.

Автор: Богдан Новотарский – разработчик, изучающий Fullstack-разработку и делящийся своим опытом в IT.

Следите за новыми статьями:
GitHub: https://github.com/bogdan-novotarskij
Twitter: https://x.com/novotarskijb

Top comments (0)