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

Конвенції TypeScript, які забезпечують суворість та гнучкість коду

Практичні думки щодо TypeScript: режим strict, unknown over any, розріджені твердження, об’єднання рядків, типи проти інтерфейсів, атрибути 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 для читачів, які хочуть отримати офіційну формулювання.

Розглядайте цей список як динамічну угоду команди, а не як незмінну доктрину на все часи. Погоджуюся.