Strona główna / Artykuły / Konwencje TypeScript, które zapewniają kodowi ścisłość i elastyczność

Konwencje TypeScript, które zapewniają kodowi ścisłość i elastyczność

Praktyczne opinie na temat TypeScript: tryb strict, unknown over any, rzadkie twierdzenia, unie ciągów znaków, typ vs interfejs, atrybuty JSDoc, narzędzia pomocnicze oraz unie z dyskryminacją.

1535 słów

Dobry TypeScript powinien zmniejszyć szanse na wysyłkę błędnego kodu, nie przekształcając przy tym każdego pliku w formalność. Poniższe konwencje sprzyjają prostszym, bezpieczniejszym i łatwiejszym w utrzymaniu rozwiązaniom, zachowując jednocześnie elastyczność, która czyni TypeScript przydatnym na co dzień.

TL;DR

  • Opisz dokładnie umowy na poziomie modułów i komponentów.
  • Pozwól narzędziu sprawdzającemu wywnioskować prywatne pomocniki i zmienną lokalne.
  • Zachowuj krótkie i celowe wyłączenia błędów.
  • Wykorzystuj istniejące aliasy i narzędzia z podręcznika, zanim wymyślisz nowe.
  • W przypadku alternatywnych stanów preferuj łączenia literackie i warianty oznaczone tagami.

Włącz tryb ścisły

Zawsze włącz ścisłe sprawdzanie w pliku tsconfig.json (podręcznik):

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

Opcje ścisłe pomagają wcześniej wykryć powszechne błędy i utrzymują wyższy poziom poprawności w coraz większej bazie kodu.

any

Gdy typ wartości jest niepewny, lepiej użyć unknown zamiast any, ponieważ jego użycie wymaga najpierw zawężenia zakresu możliwych wartości:

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

Ignowowanie sprawdzania typów

Gdy faktycznie konieczne jest obejście reguł, należy utrzymać taką możliwość w jak najmniejszym stopniu.

Zastosowanie instrukcji typowania as any do pojedynczego wyrażenia często jest lepsze niż użycie @ts-ignore, ponieważ takie typowanie dotyka tylko tego wyrażenia, podczas gdy @ts-ignore może zignorować cały następny wiersz kodu.

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

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

w kodzie własnym firmy lepiej naprawić typy, dodać mechanizm ochronny lub zweryfikować dane zewnętrzne (na przykład za pomocą Zod), zamiast po prostu ignorować komunikaty sprawdzacza.

Użyj @ts-expect-error, gdy wiesz więcej niż może udowodnić sprawdzacz:

  • W odróżnieniu od @ts-ignore, kompilacja zawodzi, jeśli błąd znika niespodziewanie.
  • Dzięki temu celowe ignorowanie błędów pozostaje transparentne podczas przeglądania kodu.
<div
  style={{
    // @ts-expect-error — vars are valid CSS properties
    '--size': `${dimensions.size}px`,
  }}
/>

Używaj stwierdzeń typowych oszczędnie

as nadpisuje mechanizm sprawdzania typów. Przy możliwości preferuj potwierdzenie w czasie wykonywania kodu:

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

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

Preferuj strażniki typów zamiast stwierdzeń typowych

Strażniki typów działają tylko po pomyślnym sprawdzeniu w czasie wykonywania kodu — są bezpieczniejsze niż zwykłe 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}`);
  }
}

Używaj stwierdzeń typowych tylko wtedy, gdy sprawdzenie w czasie wykonywania kodu jest niemożliwe lub bezużyteczne — zazwyczaj gdy właściwości od dostawców trzecich nie pasują do ogólnego wzorca formularza:

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

Preferuj unie literów tekstowych zamiast enum

Dla kodu aplikacji unie literów tekstowych zwykle są lepsze od enum: nie wymagają obiektu w czasie wykonywania, dają prostsze wyniki emisji i są bardziej przyjazne podczas serializacji:

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

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

Zachowaj enum, gdy wymagany jest obiekt w czasie wykonywania kodu, jeśli istniejąca API już ich używa lub gdy standard zespołu tego wymaga.

Wynikaj unie na podstawie wartości

Gdy wartości już istnieją w czasie wykonywania, uzyskaj zbiór z jednego źródła prawdy:

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

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

Forma tablicy:

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

Wymagana jest deklaracja typu const, aby tablica pozostała zbiorem literali, a nie string[].

Niech type będzie preferowany przed interface

Dla większości kodu aplikacji type obejmuje struktury obiektów oraz zbiory, przecięcia, typy mapowane i warunkowe. Nie można go również ponownie otworzyć w celu przypadkowego połączenia:

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

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

Użycie jednej formy ogranicza niepotrzebną zmianę między nakładającymi się funkcjonalnościami.

Używaj interfejsu tylko wtedy, gdy jego funkcje są istotne

Biblioteki publiczne mogą celowo eksportować interface, aby użytkownicy mogli je rozszerzyć:

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

Dokumentuj właściwości komponentów

Używaj JSDoc (/** ... */) dla właściwości publicznych lub trudnych do zrozumienia – dotyczących ich przeznaczenia, zachowania i ograniczeń – gdy kontekst jest jeszcze świeży:

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;

Wyjątki w dokumentacji

Pomijaj obszerne dokumentacje, gdy:

  • Komponent jest używany tylko raz i raczej nie będzie powracał
  • Jest to prywatne dziecko udokumentowanego rodzica, którego specyfikacja już wyjaśnia jego zachowanie

Gdy masz wątpliwości, daj pierwszeństwo jasności i zachowuj spójność.

Ponowne użycie istniejących typów

Czyniąc API publicznymi, bądź precyzyjny; niech mechanizmy inferencji zajmują się elementami wewnętrznymi. Zanim stworzysz nową abstrakcję, poszukaj istniejącego typu, właściwości lub narzędzia do ponownego użycia, aby powiązany kod pozostał spójny.

Dostęp do właściwości

Dostęp przez indeks pobiera typ właściwości z istniejącego aliasu:

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

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

function SpecialThing(props: SpecialThingProps) {}

Niech intersekcje służą kompozycji

Łącz typy bez duplikowania pól (oraz ich dokumentacji):

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) {}

Szczegóły implementacji inferencji

Inferencja utrzymuje wewnętrzne typy zsynchronizowane z wartościami:

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

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

ComponentProps wywodzi właściwości z komponentu React, gdy biblioteka ich nie eksportuje:

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 zwraca tupel argumentów, który można indeksować:

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

Korzystaj z wbudowanych typów pomocniczych

Typy pomocnicze obejmują częste transformacje i są łatwe do zrozumienia dla nowych członków zespołu:

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

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

Napisz własny typ pomocniczy tylko wtedy, gdy transformacja odnosi się do rzeczywistego pojęcia z danej dziedziny, a nie tylko po to, by zaoszczędzić klawisze.

Używaj zjednoczeń dyskryminowanych

Zjednoczenia oznaczone umożliwiają modelowanie alternatyw, dzięki czemu możliwe jest sprawdzanie kompletności:

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

Wspólne pole literalne (type lub kind) pozwala TypeScriptowi bezpiecznie zawęzić każdą gałąź.

Podsumowanie

Jednolitość jest ważniejsza od jakiegokolwiek pojedynczego zasady. Umówcie się na konwencjach, udokumentujcie wyjątki i optymalizujcie pod kątem przyszłego czytelnika:

  • Jasne zasady obsługi granicowych przypadków
  • Wnioskowane elementy wewnętrzne
  • Minimalna liczba sytuacji tłumionych
  • Wspólne aliasy i narzędzia pomocnicze
  • Zespoły oznaczone tagami, w których nielegalne kombinacje muszą zniknąć

Gdy są stosowane razem, te nawyki sprawiają, że TypeScript służy jako narzędzie projektowe, a nie obciążenie: publiczne API pozostają uczciwe, elementy wewnętrzne są zgodne z zasadą DRY, a pozostałe przekształcenia typów any lub as stają się rzadkością – są to wyjątki podlegające weryfikacji, a nie domyślna droga ucieczki.

Takie odniesienia znajdują się w oficjalnym podręczniku TypeScript oraz dokumentacji JSDoc dla czytelników, którzy chcą poznać kanoniczne sformułowania.

Rozpatrujcie tę listę jako żywą umowę zespołu, a nie jako stałą doktrynę na zawsze. Zgoda.

Literatura pokrewna