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.
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:
unknownmówi: „Jeszcze tego nie wiem.”anymó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
- Powszechne pułapki JavaScript i TypeScript, które cicho niszczą kod — Wyjaśnia subtelne problemy w JavaScript i TypeScript – od porównań z NaN po kwestie związane z asynchronicznym działaniem i przymusową konwersją typów – które powodują błędy mimo pozornie poprawnego kodu.
- Zamiana
anyw TypeScript na sześć bezpiecznych wariantów dla częstych przypadków — Poznaj praktyczne, bezpieczne pod względem typów alternatywy dlaanyw TypeScript – w tym typy unknown, generyki, zespoły rozróżniające oraz pełne sprawdzenia – służące do obsługi nieprzewidywalnych danych.