Главная / Статьи / Конвенции TypeScript, обеспечивающие строгость и гибкость кода

Конвенции TypeScript, обеспечивающие строгость и гибкость кода

Практические мнения о TypeScript: строгий режим, тип unknown вместо любого типа, утверждения sparse, объединение строк, различия между типом и интерфейсом, атрибуты JSDoc, утилиты и дискриминированные объединения.

1535 слов

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

Кратко

  • Опишите условия использования модулей и компонентов подробно.
  • Разрешите инструменту проверки автоматически определять внутренние помощнические функции и локальные переменные.
  • Используйте отключения проверок только там, где это действительно необходимо и осознанно.
  • Используйте существующие псевдонимы и утилиты вместо того, чтобы создавать новые.
  • В случае альтернативных состояний предпочитайте литеральные объединения и варианты с метками.

Включите строгий режим

Всегда включайте строгую проверку в файле tsconfig.json (см. руководство):

{
  "compilerOptions": {
    "strict": true
  }
}

Опции строгого режима позволяют выявлять распространённые ошибки на ранних этапах и поддерживать высокий уровень корректности в постоянно растущей базе кода.

Избегайте типа any

Когда тип значения неизвестен, предпочтите unknown вместо any, поскольку для его использования сначала необходимо уточнить тип:

// bad
function processValue(value: any) {
  value.toLowerCase(); // No error, but might crash at runtime
}

// good
function processValue(value: unknown) {
  if (typeof value === 'string') {
    value.toLowerCase(); // OK, we've verified it’s a string
  }
}

Игнорирование проверок типов

Когда действительно требуется обойти проверку, делайте этот способ максимально ограниченным.

Преобразование отдельного выражения с помощью as any часто лучше, чем использование @ts-ignore, поскольку преобразование влияет только на это выражение, тогда как @ts-ignore может отключить проверку всей следующей строки.

// third-party library with incomplete type definitions
import { someExternalLib } from 'external-lib';

const result = (someExternalLib.complexMethod() as any).undocumentedProperty;

В коде первой стороны предпочтите исправление типов, добавление защитных мер или проверку внешних данных (например, с помощью Zod), вместо отключения проверки.

Используйте @ts-expect-error, когда вы знаете больше, чем может доказать проверщик:

  • В отличие от @ts-ignore, компиляция сбрасывается, если ошибка исчезает неожиданно.
  • Это позволяет сохранять честность намеренных подавлений ошибок при ревью.
<div
  style={{
    // @ts-expect-error — vars are valid CSS properties
    '--size': `${dimensions.size}px`,
  }}
/>

Используйте утверждения типов осторожно

as — утверждения переопределяют механизм проверки. По возможности предпочитайте подтверждение во время выполнения программы:

// bad
const myCanvas = document.getElementById('canvas') as HTMLCanvasElement;

// good
const myCanvas = document.getElementById('canvas');
if (myCanvas instanceof HTMLCanvasElement) {
  const ctx = myCanvas.getContext('2d');
}

Вместо утверждений предпочитайте защитники типов

Защитники типов применяются только после успешной проверки во время выполнения; по сравнению с простым as они обеспечивают более надежную защиту:

type User = {
  id: string;
  name: string;
};

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof value.id === 'string' &&
    'name' in value &&
    typeof value.name === 'string'
  );
}

function greet(value: unknown) {
  if (isUser(value)) {
    // value is User here - no assertion needed
    console.log(`Hello, ${value.name}`);
  }
}

Используйте утверждения типов лишь тогда, когда проверка во время выполнения невозможна или бесполезна — обычно это происходит, когда параметры от сторонних поставщиков не соответствуют общему формату данных:

<OTPInput
  // Our form pattern expects a traditional change handler but this third-party
  // component provides the text value as its only callback argument. We need
  // to manually create an event-like object so validation works as expected
  // for consumers.
  onChange={(value) => {
    props.onChange?.({
      target: { value },
    } as ChangeEvent<HTMLInputElement>);
  }}
/>

Вместо перечислений предпочитайте объединения строковых литералов

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

// bad
enum Environments {
  DEV = 'dev',
  TESTING = 'test',
  STAGING = 'staging',
  PRODUCTION = 'prod',
}

// good
type Environments = 'dev' | 'test' | 'staging' | 'prod';

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

Определяйте объединения на основе значений

Когда значения уже существуют во время выполнения, определяйте объединение из единого источника правды:

const ENVIRONMENT_META = {
  dev: {},
  test: {},
  staging: {},
  prod: {},
};

type Environments = keyof typeof ENVIRONMENT_META;
//   ^? 'dev' | 'test' | 'staging' | 'prod'

В формате массива:

const ENVIRONMENTS = ['dev', 'test', 'staging', 'prod'] as const;
type Environments = (typeof ENVIRONMENTS)[number];
//   ^? 'dev' | 'test' | 'staging' | 'prod'

Требуется утверждение типа const, чтобы массив оставался туплом литералов, а не string[].

Предпочитайте type вместо interface

Для большинства кодов приложений type покрывает структуры объектов, а также объединения, пересечения, отображаемые и условные типы. Кроме того, его нельзя повторно открыть для случайного слияния:

// when required
interface Person {
  name: string;
  email: string;
}

// preferred
type Person = {
  name: string;
  email: string;
};

Использование одной формы сокращает ненужную смену между перекрывающимися функциями.

Используйте interface только тогда, когда его функции важны

Публичные библиотеки могут намеренно экспортировать interface, чтобы потребители могли расширить его:

// library
export interface Theme {
  colors: Record<string, string>;
}

// consumer: extend the interface without modifying the original source
declare module 'my-library' {
  interface Theme {
    spacing: Record<string, number>;
  }
}

Документируйте свойства компонентов

Используйте JSDoc (/** ... */) для публичных или неочевидных свойств — их назначение, поведение, ограничения — пока контекст ещё свеж:

type SearchFieldProps = {
  /**
   * A unique identifier for the field. Consider [useId](https://react.dev/reference/react/useId) to generate a unique ID.
   */
  id?: string;
  /**
   * The label of the field. For "visually hidden" labels, use the `aria-label`
   * attribute.
   */
  label?: string;
  /**
   * @deprecated Use `label` instead.
   */
  title?: string;
  /**
   * Handler that is called when the field is submitted.
   */
  onSubmit?: (value: string) => void;
  /**
   * Handler that is called when the clear button, or <Escape> key, is pressed.
   */
  onClear?: () => void;
} & AriaLabellingProps;

Исключения в документации

Пропускайте обширную документацию в следующих случаях:

  • Компонент используется один раз и вряд ли будет использоваться снова
  • Это приватный подкомпонент документированного родителя, чьи требования уже объясняют его поведение

При сомнениях отдавайте предпочтение ясности и соблюдайте последовательность.

Повторное использование существующих типов

Сделайте публичные API явными; пусть инференция занимается внутренними деталями. Прежде чем создавать новую абстракцию, поищите существующий тип, свойство или утилиту для повторного использования, чтобы соответствующий код оставался согласованным.

Доступ к свойствам

Индексированный доступ берёт тип свойства из существующего псевдонима:

import type { ThingProps } from '../thing';

type SpecialThingProps = {
  ...
  label?: ThingProps['label'];
  type?: ThingProps['type'];
};

function SpecialThing(props: SpecialThingProps) {}

Для комбинирования типов предпочитайте пересечения

Сливайте типы, не дублируя поля (и их документацию):

type Person = { name: string; email: string };
type User = Person & { id: string };
//   ^? { name: string; email: string; id: string }
import type { ThingProps } from '../thing';

type SpecialThingProps = {
  ...
} & Pick<ThingProps, 'label' | 'type'>;

function SpecialThing(props: SpecialThingProps) {}

Детали реализации вывода

Процесс вывода поддерживает синхронизацию внутренних типов с значениями:

function initContext() {
  return {
    name: 'Jane Doe',
    email: 'jane.doe@example.com',
  };
}

type Context = ReturnType<typeof initContext>;
//   ^? { name: string; email: string }

ComponentProps генерирует атрибуты компонента React, когда библиотека не экспортирует их:

import type { ComponentProps } from 'react';
import { RouterProvider } from 'react-aria-components';

type RouterProviderProps = ComponentProps<typeof RouterProvider>;
//  {
//    navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
//    useHref?: (href: Href) => string;
//    children: ReactNode;
//  }

Parameters возвращает кортеж аргументов, по которому можно осуществлять индексацию:

// library code

declare function someLibFn(
  value: number,
  options: {
    minimumFractionDigits: number;
    maximumFractionDigits: number;
  }
): void;

// consumer code

import { someLibFn } from 'some-lib';

type LibFnValue = Parameters<typeof someLibFn>[0];
//   ^? number
type LibFnOptions = Parameters<typeof someLibFn>[1];
//   ^? { minimumFractionDigits: number, maximumFractionDigits: number }

Использование встроенных типов-утилит

Типы-утилиты охватывают распространённые преобразования и легко понятны новым коллегам:

type User = {
  id: string;
  name: string;
  email: string;
};

type UserPatch = Partial<User>;
type UserDetails = Omit<User, 'id'>;
type UserIdentity = Pick<User, 'id' | 'email'>;

Пишите собственные утилиты только тогда, когда название преобразования соответствует реальному понятию из определённой области, а не просто для экономии клавишных нажатий.

Использование дискриминированных союзов

Маркированные союзы моделируют альтернативные варианты, что позволяет выполнять проверку полноты охвата:

type Actions =
  | { type: 'login'; username: string }
  | { type: 'logout'; reason: 'session-timeout' | undefined }
  | { type: 'update'; id: string; data: Record<string, unknown> }

Общее поле-литерал (type или kind) позволяет TypeScript безопасно сужать диапазон рассмотрения каждой ветви.

Итог

Согласованность важнее любого отдельного правила. Договоритесь о стандартах, задокументируйте исключения и оптимизируйте материал с учётом будущих читателей:

  • Чёткие условия работы на граничных случаях
  • Внутренние механизмы, определяемые извне
  • Минимальное использование механизмов подавления ошибок
  • Общие псевдонимы и утилиты
  • Объединения с метками, при которых недопустимые комбинации должны исчезать

Если применять все эти принципы вместе, TypeScript останется инструментом для проектирования, а не обузой: публичные API будут честными, внутренние элементы — простыми и переиспользуемыми, а использование типов any или as станет редким явлением, представляя собой исключения, требующие проверки, а не стандартный способ обхода правил.

Эти ссылки содержатся в официальном руководстве TypeScript и документации JSDoc для тех, кто хочет ознакомиться с каноническим формулированием.

Рассматривайте этот список как динамическое соглашение команды, а не как незыблемую доктрину на всё время. Согласны.