Strona główna / Artykuły / Modelowanie domen w TypeScript: Poza podstawowymi adnotacjami typów

Modelowanie domen w TypeScript: Poza podstawowymi adnotacjami typów

Naucz się praktycznych nawyków w TypeScript – od rozróżniania typów „unknown” i „any” po zastosowanie unii dyskryminowanych oraz funkcji „satisfies” – które pomogą ci modelować ważne stany, a nie tylko oznaczać dane.

2330 słów

TypeScript jest zaskakująco prosty w opanowaniu.

Najpierw uczysz się interfejsów.

Następnie aliasów typów.

Potem przychodzą unie, generyki, typy pomocnicze oraz od czasu do czasu typy mapowane.

Niedługo potem możesz rzucić okiem na zwykły obiekt JavaScript i bez wahania nadać mu typ.

Jednak w pewnym momencie praca z TypeScriptem przestaje polegać na przypinaniu typów do obiektów.

Zmienia się to w celowe projektowanie typów.

To zupełnie inna umiejętność, którą trzeba rozwijać.

Rozważmy ten przykład:

type Payment = {
  status: 'SUCCESS' | 'FAILED'
  transactionId?: string
  error?: string
}

Na pierwszy rzut oka wydaje się w porządku.

Ale zastanów się, jakie stany ten typ technicznie dopuszcza.

Dopuszcza wszystkie te:

{
  status: 'SUCCESS'
}

{
  status: 'SUCCESS',
  error: 'Something went wrong'
}

{
  status: 'FAILED',
  transactionId: '123'
}

{
  status: 'FAILED',
  error: 'Something went wrong'
}

Typ nie ma pojęcia o tym, które kombinacje faktycznie mają sens razem.

To nie jest ograniczenie samego TypeScriptu.

To oznaka, że domena została źle zaprojektowana.

Lepsza wersja wygląda tak:

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Teraz system typów koduje bezpośrednio rzeczywistą zasadę biznesową.

Płatność udana musi zawierać identyfikator transakcji.

Płatność nieudana musi zawierać komunikat o błędzie.

Kombinacje, które nie mają sensu, stają się trudne lub wręcz niemożliwe do utworzenia.

To właśnie w tym momencie TypeScript zaczyna być naprawdę przydatny.

Celem nie jest umieszczanie adnotacji typów wszędzie, gdzie to możliwe.

Celem jest sprawienie, by twoje typy wyrażały zasady, których faktycznie przestrzega twoje aplikacja.

Poniżej znajdują się kilka nawyków, które pomagają iść w tym kierunku.

1. Przestań używać any, gdy tak naprawdę masz na myśli „Nie wiem”

Jednym z najszybszych sposobów na uciszenie komunikatu ostrzegawczego TypeScript jest ten:

const response: any = await fetchData()

Czasami rzeczywiście tak się dzieje.

Potykasz się o błąd.

Jesteś w trakcie implementacji.

Nie możesz od razu określić właściwego typu.

Dlatego używa się any.

Kompilator milknie.

Ale znika również cała pomoc, którą oferował TypeScript.

Gdy tylko any dostać się do twojego kodu:

const response: any = await fetchData()

response.user.profile.name // not checked
response.foo.bar.baz // not checked

TypeScript nie ma sposobu, aby wykryć żaden z tych błędów.

Użyj unknown, gdy wartość jest rzeczywiście nieznana

const response: unknown = await fetchData()

To zmusza cię do faktycznego określenia, czym jest wartość, zanim ją użyjesz.

if (typeof response === 'string') {
  console.log(response.toUpperCase())
}

W przypadku czegokolwiek bardziej złożonego niż prymitywa, sprawdź strukturę na granicy zamiast tego.

Różnica jest tu istotna:

unknown mówi: „Jeszcze tego nie wiem.” any mówi: „W ogóle nie chcę, żeby TypeScript to sprawdzał.”

To są dwa zupełnie różne zamiary.

Gdy przetwarzasz dane pochodzące spoza twojego systemu, unknown jest niemal zawsze bardziej trafnym punktem wyjścia.

2. Nie wpisywaj tego, co TypeScript już wie

Pisanie silnie typowanego kodu nie oznacza ręcznego adnotowania każdej zmiennej.

Ta wersja:

const name: string = 'Akshat'
const age: number = 30
const active: boolean = true

nie jest z natury lepsza od tej:

const name = 'Akshat'
const age = 30
const active = true

TypeScript może samodzielnie wywnioskować te typy.

Adnotowanie wszystkiego tylko dodaje wizualnego bałaganu bez dostarczania rzeczywistych informacji.

Jawnie określone adnotacje mają sens tylko wtedy, gdy przekazują coś istotnego.

Na przykład:

function calculateTotal(
  items: Product[],
  discount: number
): number {
  // ...
}

Tutaj sygnatura funkcji faktycznie dokumentuje część umowy.

To naprawdę przydatna informacja.

Pomocną sprawdzeniem jest:

Czy ta adnotacja mówi TypeScriptowi coś, czego nie mogło samodzielnie ustalić?

Jeśli odpowiedź brzmi „nie”, prawdopodobnie możesz ją pominąć.

3. Używaj as const, gdy wartości są również typami

Rozważmy taki obiekt:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
}

Czasami chcesz, aby poszczególne wartości pozostały typami literalem, zamiast zostać przekształconymi w string.

Dokładnie to daje as const:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

Po tym:

type Status = typeof STATUS[keyof typeof STATUS]

dochodzi do:

'ACTIVE' | 'INACTIVE'

Ten wzorzec sprawdza się, gdy potrzebujesz zarówno wartości w czasie wykonywania, jak i odpowiadającego im typu w czasie kompilacji z jednej definicji.

Naprzимер:

export const ALTERNATE_CODE_TYPES = {
  CHARGE_CODE: 'CHARGE_CODE',
  NFTP_MDG_CODE: 'NFTP_MDG_CODE',
  FACT_MDG_CODE: 'FACT_MDG_CODE',
  CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const

export type AlternateCodeType =
  typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]

Tutaj sam obiekt oraz typ pochodny mają wspólne źródło.

To oznacza, że unikasz konieczności utrzymywania oddzielnej deklaracji w stylu:

type AlternateCodeType =
  | 'CHARGE_CODE'
  | 'NFTP_MDG_CODE'
  | 'FACT_MDG_CODE'
  | 'CW1_CHARGE_CODE'

na dodatek.

Zachowanie jednego źródła prawdy jest znacznie łatwiejsze do zarządzania niż ręczne synchronizowanie dwóch definicji.

4. Używaj typów unii, gdy domena ma ustalony zbiór stanów

Gdy wartość może przyjmować tylko kilka możliwych wartości, twoje typy powinny to bezpośrednio wskazywać.

Zamiast pisać:

function setStatus(status: string) {
  // ...
}

wolij:

type Status = 'pending' | 'approved' | 'rejected'

function setStatus(status: Status) {
  // ...
}

Przy takim podejściu następująca wywołanie działa poprawnie:

setStatus('approved')

ale to drugie zostaje odrzucone:

setStatus('something-else')

Im bardziej precyzyjny jest typ, tym więcej może zrobić za Ciebie kompilator.

Korzyści te wykraczają daleko poza funkcję autodopowiadania w edytorze. Dokładna unia pomaga również przy:

  • refaktoryzacji
  • dokumentacji
  • wykrywaniu błędów
  • projektowaniu API
  • ułatwianiu znajdowania informacji

Jeśli Twoja logika biznesowa faktycznie dopuszcza tylko trzy możliwe wartości, nie reprezentuj tego pola jako zwykłego ciągu znaków.

5. Nie uciekaj się automatycznie do enum

Enum są czasami odpowiednim narzędziem, ale nie powinny być domyślnym wyborem dla każdej grupy stałych.

Jeśli potrzebujesz tylko unii w czasie kompilacji, wystarczy coś takiego jak:

type Status = 'ACTIVE' | 'INACTIVE'

często jest to wystarczające.

Jeśli potrzebujesz również, aby te wartości istniały w czasie wykonywania programu, użyj:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

type Status = typeof STATUS[keyof typeof STATUS]

Dzięki temu masz zarówno typ, jak i rzeczywisty obiekt do pracy.

Kluczową kwestią, którą należy zapamiętać, jest to, że typy TypeScript znikają po uruchomieniu kodu. Zwykły obiekt tak nie robi.

Zatem należy zadać sobie pytanie:

Czy ta wartość musi istnieć w czasie wykonywania, czy jest tam tylko po to, by ograniczyć działanie kodu w czasie kompilacji?

Wybierz podejście, które odpowiada odpowiedzi.

6. Utrudnij reprezentację nieważnych stanów

To może być najcenniejsza idea z całej tej dyskusji.

Załóżmy komponent formularza, który może znajdować się w jednym z następujących stanów:

  • loading
  • ready
  • submitting
  • successful
  • failed

Typowym, choć błędnym, sposobem na modelowanie tego jest:

type FormState = {
  loading: boolean
  submitting: boolean
  error?: string
  data?: FormData
}

Z taką strukturą nic nie stoi na przeszkodzie, by przypadkowo stworzyć coś w rodzaju:

{
  loading: true,
  submitting: true,
  data: {...},
  error: 'Something went wrong'
}

Czym właściwie jest ta kombinacja? System typów nie ma pojęcia, podobnie jak następny programista, który to przeczyta.

Lepsza struktura łączy pola ze sobą w zależności od stanu:

type FormState =
  | { status: 'loading' }
  | { status: 'ready'; data: FormData }
  | { status: 'submitting'; data: FormData }
  | { status: 'success'; data: FormData }
  | { status: 'error'; error: string }

Teraz każda gałąź zawiera dokładnie te dane, które są dla niej sensowne.

function render(state: FormState) {
  switch (state.status) {
    case 'loading':
      return 'Loading...'
    case 'ready':
      return state.data
    case 'submitting':
      return 'Submitting...'
    case 'success':
      return state.data
    case 'error':
      return state.error
  }
}

To właśnie dają możliwości zjednoczeń dyskryminowanych.

Zamiast modelować aplikację jako zbiór niepowiązanych ze sobą wartości logicznych i opcjonalnych pól, modeluje się ją jako ustalony zbiór prawidłowych stanów. To o wiele bardziej niezawodna baza.

7. Bądź ostrożny z właściwościami opcjonalnymi

Pola opcjonalne mają swoje zastosowania, ale są także łatwym sposobem na ukrycie niepewności bez żadnego zauważenia.

Weźmy ten przykład:

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

Dzięki tej definicji każdy fragment kodu, który korzysta z obiektu User, musi teraz obsługiwać przypadek, gdy żadne z tych pól nie jest obecne.

Ale być może prawdziwą zasadą w tym obszarze jest:

Każdy użytkownik zawsze ma identyfikator, imię i adres e-mail.

Jeśli to prawda, należy tak to zaprojektować:

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

Atrybuty opcjonalne powinny odzwierciedlać pola, które naprawdę czasami są nieobecne. Nie służą one do zastępowania niejasnego przyznania, że osoba, która zaprojektowała ten typ, nie była pewna, co dokładnie odesła API.

Jeśli niepewność pochodzi z jakiegoś zewnętrznego systemu, należy ją rozwiązać właśnie na tym poziomie integracji. Nie pozwól, by niepewność wynikająca z jednej integracji rozprzestrzeniła się na całą bazę kodu.

8. Zrozumienie różnicy między null a undefined

type User = {
  middleName: string | null
}

Pole istnieje, ale celowo nie ma dla niego wartości.

type User = {
  middleName?: string
}

Pole może w ogóle nie istnieć.

{
  middleName: null
}

{}

Pamiętaj, że typy istnieją po to, by przekazywać znaczenie, a nie tylko zadowalać kompilatora.

9. Używaj satisfies zamiast ślepego twierdzenia typów

Rozważmy typ konfiguracji w następujący sposób:

type Config = {
  timeout: number
  retries: number
}

Jedną z opcji jest napisanie:

const config = {
  timeout: 5000,
  retries: 3,
} as Config

Ale as to twierdzenie, a jego użycie polega zasadniczo na poleceniu kompilatorowi przyjęcia wartości bez żadnych pytań.

Zazwyczaj lepszym podejściem jest:

const config = {
  timeout: 5000,
  retries: 3,
} satisfies Config

W tej wersji TypeScript faktycznie sprawdza, czy obiekt pasuje do typu Config, zachowując jednocześnie węższy, wywnioskowany typ samego obiektu literalnego.

Prosty sposób, by zapamiętać różnicę:

as

Traktuj tę wartość tak, jakby należała do tego typu.

satisfies

Potwierdź, że ta wartość spełnia wymagania tego typu.

To właśnie sprawia, że satisfies jest szczególnie przydatny dla obiektów konfiguracyjnych, statycznych mapowania oraz tabel wyszukiwania.

10. Traktuj as jako granicę, a nie narzędzie domyślne

Są sytuacje, gdy rzeczywiście wymagana jest asercja typu. Ale pisanie czegoś takiego:

const user = response as User

w rzeczywistości nie sprawdza niczego w czasie wykonywania.

Załóżmy, że wywołanie API rzeczywiście zwróciło:

{
  username: 'akshat'
}

TypeScript nie ma sposobu na wykrycie tej niespójności, ponieważ asercja już kazała mu przyjąć wartość taką, jaka jest. Nic nie jest tu udowadniane kompilatorowi — po prostu prosi się go, by to zignorował.

Sytuacja ta staje się ryzykowna w miejscach, gdzie dane trafiają do kodu z zewnątrz, na przykład:

  • odpowiedzi API
  • localStorage
  • parametry URL
  • Zmienne środowiskowe
  • dane wprowadzone przez użytkownika
  • biblioteki third-party

Każdy raz, gdy dane trafiają do aplikacji z źródła, którego TypeScript nie może zaobserwować, warto je zweryfikować zamiast je przekształcać. Sprawdzenie schematu w czasie wykonywania może faktycznie potwierdzić coś takiego jak:

"Te dane rzeczywiście odpowiadają strukturze oczekiwanej przez aplikację."

To o wiele silniejsza gwarancja niż zwykłe napisanie:

value as User

TypeScript to narzędzie do czasu kompilacji, i to bardzo dobre. Nigdy nie zostało stworzone po to, by weryfikować to, co dzieje się podczas wykonywania programu.

Prawdziwy cel: modelowanie domeny

Gdy ta mentalność się ugruntuje, TypeScript przestaje wydawać się ćwiczeniem składniowym. Zamiast pytać „jak wpisać ten obiekt?”, zaczynasz się pytać „w jakich stanach może faktycznie znajdować się ten obiekt?”. Zamiast pytać „czy ta właściwość powinna być opcjonalna?”, zaczynasz się pytać „czy ta właściwość jest rzeczywiście opcjonalna, czy po prostu ukrywa coś, czego jeszcze nie znamy?”. Zamiast pytać „czy można tu użyć as?”, zaczynasz się pytać „czy można faktycznie udowodnić, że ta wartość ma deklarowany typ?”

To właśnie jest sedno zmiany. Pisanie dobrego kodu w TypeScript nie polega na dodawaniu coraz więcej adnotacji typów – chodzi o to, by typy, które faktycznie wpisujemy, miały rzeczywiste znaczenie.

Prosta zasada do zapamiętania

Zawsze, gdy projektujesz typ, przesuń go przez trzy pytania:

1. Które stany są faktycznie dopuszczalne?

Jeśli dany typ pozwala reprezentować nieważne stany, prawdopodobnie konieczna jest ponowna analiza samego modelu.

2. Co już wie kompilator?

Unikaj dodawania adnotacji wyłącznie z przyzwyczajenia — pozwól systemowi inferencji wykonywać pracę, do której już jest zdolny.

3. Gdzie te dane stają się wiarygodne?

Im dalej dane podróżują od swojego pierwotnego źródła zewnętrznego, tym większą pewność ich typy powinny być w stanie wyrazić.

Mocny TypeScript nie jest definiowany przez stopień złożoności jego typów. Jest definiowany przez typy, które sprawiają, że poprawna implementacja jest oczywista, a błędna trudna do napisania.

Gdy typy są projektowane z takim podejściem, TypeScript przestaje wydawać się warstwą przymocowaną do JavaScriptu. Staje się częścią sposobu, w jaki faktycznie budowana jest aplikacja.

Literatura pokrewna

  • Opanuj wbudowane tipy użyteczne TypeScript dla czystszego kodu — Dowiedz się, jak tipy użyteczne TypeScript takie jak Partial, Pick, Omit i Record eliminują duplikatowe interfejsy i automatycznie synchronizują definicje typów.