Strona główna / Artykuły

Co kompilator TypeScript wykrywa, a co JavaScript pozwala przejść bez kary

Porównaj JavaScript i TypeScript obok siebie: inferencja typów, adnotacje, wartości pierwotne, any, unie oraz funkcje typowane, a także to, co faktycznie wykrywa i wyrzuca tsc.

Wydawca: REACTAPP.TOP14 min czytania3122 słów

Źródła: JavaScript vs TypeScript: Understanding Type Safety

JavaScript może wykonać praktycznie wszystko, co mu podasz, i to właśnie dlatego niektóre błędy pojawiają się tylko w środowisku produkcyjnym. TypeScript dodaje krok sprawdzania przed uruchomieniem kodu, ale łatwo jest niezrozumieć, co ten krok robi i przed czym cię nie chroni. Ten przewodnik omawia małe przykłady z użyciem typów, od przypisywania nowych wartości zmiennym po funkcje typowane, abyś mógł dokładnie zobaczyć, gdzie JavaScript milczy, gdzie kompilator TypeScript interweniuje oraz jak go skonfigurować tak, aby błędy rzeczywiście blokowały proces budowania.

Typowanie dynamiczne versus typowanie statyczne

JavaScript charakteryzuje się typowaniem dynamicznym. Zmienna nie ma ustalonego typu; typ należy do wartości, którą aktualnie przechowuje, jest określany w trakcie działania programu i może ulegać zmianie w każdej chwili.

TypeScript to statycznie typowany superset JavaScripta. Każdy poprawny program w JavaScriptu jest również syntaktycznie poprawnym TypeScriptem, ale TypeScript dodaje narzędzie do sprawdzania typów, które analizuje kod w czasie kompilacji, zanim cokolwiek zostanie wykonywane. Następnie narzędzie to usuwa informacje o typach i generuje zwykły JavaScript.

Wszystko inne w tym przewodniku wynika z tej jednej różnicy. Określanie typów w czasie wykonywania sprawia, że JavaScript jest elastyczny, ale pozwala na przeoczenie błędów. Określanie typów z góry sprawia, że TypeScript jest bardziej rygorystyczny, ale ujawnia wiele błędów wcześnie, co właśnie ma na myśli ludzie, mówiąc, że TypeScript jest bezpieczny pod względem typów. Ściśle mówiąc, ta bezpieczeństwo jest gwarancją z czasu kompilacji – obowiązuje tylko w zakresie, jaki widzi narzędzie do sprawdzania typów.

Kiedy zmienna zmienia typ

W zwykłym JavaScriptie zacznij od zmiennej liczbowej i sprawdź jej typ za pomocą typeof:

let age = 30;
console.log(typeof(age));  // output is number

Kilka wierszy później nic nie stoi na przeszkodzie, by przechować ciąg znaków w tej samej zmiennej:

age = "John";
console.log(age);
console.log(typeof(age)); // string

JavaScript akceptuje to bez ostrzeżeń ani błędów. Pierwszy typeof zwraca wartość "number", a drugi "string" – jedna zmienna, dwa niepowiązane typy w jej życiowym cyklu. W małym skrypcie jest to nieszkodliwe; w dużym bazie kodu każdy kod, który nadal oczekuje liczby z zmiennej age, będzie teraz działać niewłaściwie, daleko od wiersza, który to spowodował.

TypeScript odrzuca taki kod:

let data = 2;
data = "two"; // ❌ Error: Type 'string' is not assignable to type 'number'

Gdy TypeScript widzi let data = 2;, na podstawie wartości początkowej wnioskuje, że data ma typ number, i zachowuje ten typ przez cały okres istnienia zmiennej. Takie zachowanie nazywa się wnioskowaniem typu. Dlatego też przypisanie wartości "two" w następnej linii jest odrzucane przez kompilator jeszcze przed uruchomieniem programu.

Zamiast polegać na wnioskowaniu, możesz sam określić typ:

let data: number = 2;

Część : number to składnia charakterystyczna wyłącznie dla TypeScript i nie istnieje w JavaScript. Pod względem bezpieczeństwa typowego obie formy są równoważne; wersja z adnotacją po prostu ułatwia zrozumienie intencji każdemu, kto później będzie czytał kod, włączając ciebie za sześć miesięcy.

Zasada, którą należy pamiętać: JavaScript pozwala zmiennym zmieniać typy tak często, jak chcemy, natomiast TypeScript ustala typ zmiennej raz – albo na podstawie adnotacji, albo na podstawie jej pierwszej wartości – i egzekwuje ten typ od tego momentu.

Pułapka łańcucha znaków i liczby

Poprzedni przykład pokazuje, że TypeScript sprawdza typy. Następny wyjaśnia, dlaczego to ma znaczenie.

JavaScript ponownie bez problemu pozwala na przepisanie zmiennej, która przechowywała łańcuch znaków, wartością liczbową:

let message = "Hello";
let num = 2;
message = 20; // totally allowed in JS

A teraz klasyczny efekt zaskoczenia:

let result = "5" + 3;
console.log(result); // "53", not 8

Można by oczekiwać wartości 8, ponieważ "5" wygląda jak liczba. Jednak "5" to ciąg znaków, a gdy operator + napotyka ciągi po obu stronach, JavaScript wykonuje łączenie ciągów zamiast operacji arytmetycznej. Przekształca 3 na tekst i łączy oba elementy, co daje "53". JavaScript wykonuje to bez żadnych zastrzeżeń, ponieważ nigdy wcześniej nie sprawdza typów; po prostu robi to, co kod dosłownie nakazuje w momencie wykonywania.

Co robi TypeScript z tym samym kodem

Oto wersja w TypeScriptie, z wyraźnie określonymi typami:

let num1: string = "5";
let num2: number = 3;

A sama operacja dodawania:

let add = num1 + num2; // expected 8, but num1 is a string and num2 is a number
console.log(add); // actual result is 53

Wydrukowana wartość nadal wynosi 53. W tym miejscu wiele wyjaśnień zawodzi, więc warto być precyzyjnym. TypeScript celowo dopuszcza string + number, ponieważ łączenie tekstu z liczbą jest powszechnym i dopuszczalnym w JavaScript. Sprawdzacz nie odrzuca tej wyrażenia; wnioskuje, że add ma typ string.

Błąd operatora, który mógłeś zobaczyć, wygląda tak:

Operator '+' cannot be applied to types 'string' and 'number'.

TypeScript wyświetla tego typu komunikat w przypadku kombinacji operandów, które uznaje za bezsensowne, na przykład gdy dodaje się boolean do number lub wartości o typach, które nie mają ze sobą nic wspólnego. To nie jest to, co otrzymujemy w przypadku num1 + num2 powyżej.

W tym scenariuszu TypeScript chroni nas w momencie, gdy wynik jest używany jako liczba. Gdybyśmy napisali let add: number = num1 + num2;, kompilator poinformowałby, że typ string nie może być przypisany do typu number. Podobnie, przekazanie wartości add do funkcji oczekującej liczby spowodowałoby błąd. W praktyce właśnie w ten sposób ujawnia się błąd „5” + 3 w kodzie typowanym: nie przy znaku plusa, lecz tam, gdzie przypadkowy ciąg znaków trafia na kod, który miał otrzymać liczbę.

Dlaczego błędy domyślnie nie zatrzymują naszego programu

Istnieje jeszcze jedna subtelność. Nawet gdy tsc wykryje błąd typu, domyślnie nadal generuje skompilowany plik .js. Uruchomienie tego pliku za pomocą node powoduje wykonywanie zwykłego JS, nic więcej. Wszystkie informacje typowe zostały usunięte, pozostaje tylko logika działająca w czasie wykonywania.

To jest istota TypeScript: nie zmienia on sposobu działania twojego kodu w czasie wykonywania. Zgłasza problemy w edytorze i podczas kompilacji, zanim cokolwiek uruchomisz. Jeśli chcesz, aby kompilator odrzucał wygenerowanie wyniku przy każdym błędzie typu, włącz tę opcję w pliku tsconfig.json:

{
  "compilerOptions": {
    "noEmitOnError": true
  }
}

Gdy opcja noEmitOnError jest włączona, kod z błędami typu nigdy nie trafia do node, ponieważ tsc zatrzymuje się bez zapisywania pliku JavaScript. W większości rzeczywistych projektów ten sam efekt osiąga się w środowisku CI poprzez uruchomienie polecenia tsc --noEmit jako oddzielnego kroku sprawdzania typów, co powoduje porażkę budowania przy każdym błędzie.

Kluczowy punkt: wartość TypeScript nie polega na innym zachowaniu w czasie wykonywania, ponieważ skompilowany TypeScript to JavaScript. Jego wartość polega na wykrywaniu niezgodności przed uruchomieniem, dając ci możliwość naprawienia błędów, które w przypadku JavaScript zostałyby bezgłośnie przekazane dalej.

Annotacje i inferencja

Te dwa terminy opisują przeciwne sposoby osiągnięcia tego samego rezultatu. Rozważmy zanotowaną deklarację:

let age: number = 30;

Biorąc to element po elemencie:

  • let to słowo kluczowe deklaracji
  • age to nazwa zmiennej
  • 30 to przypisana wartość
  • number to typ
  • : number, czyli dwukropka po typie, to anotacja typu

Annotacja jasno informuje TypeScript, jaki typ ma zmienna, używając składni dwukropka, takiej jak : number, : string lub : boolean. Inferencja jest procesem odwrotnym: pomija się typ, a TypeScript wywnioskowuje go na podstawie przypisanej wartości.

let age = 30;        // inference — TS decides this is a "number" on its own
let city: string = "Delhi"; // annotation — you told TS explicitly

Oba zapewniają takie samo zabezpieczenie, ponieważ po ustaleniu typu, czy to przez użytkownika, czy przez kompilatora, jest on zamknięty. Adnotacje służą głównie do lepszej przejrzystości. Są najbardziej przydatne, gdy zmienna jest deklarowana bez wartości początkowej, ponieważ nie ma nic, z czego można by wywnioskować informacje, a także w sytuacjach dotyczących podpisów funkcji i publicznych interfejsów API, gdzie dokumentują one intencję. W przypadku zmiennych lokalnych o oczywistej wartości początkowej większość zespołów pozwala na automatyczne wywnioskowanie.

Typy prymitywne i niemprymitywne

Pomocny sposób na grupowanie typów, z którymi spotkamy się na początku:

  • Typy prymitywne, takie jak number, string, boolean, undefined i null, przechowują w każdym momencie dokładnie jedną wartość.
  • Typy nieprymitywne, czyli strukturalne, takie jak tablice, obiekty, tuple, funkcje oraz kształty opisane za pomocą klas i interfejsów, łączą kilka wartości – na przykład tablicę wyników lub rekord użytkownika zawierający imię i adres e-mail.
  • Prymityw to po prostu pojemnik zawierający jedną wartość; typ strukturalny to natomiast pojemnik, który zawiera wiele takich pojemników.

    Dwie kolejne konstrukcje, które często pojawiają się na listach dla początkujących obok prymitywów, to any oraz typy unii. Ściśle mówiąc, żadna z nich nie jest prymitywem. any służy jako wyjście awaryjne, które wyłącza sprawdzanie typów, natomiast typ unii umożliwia łączenie innych typów. Znajdują się one obok prymitywów, ponieważ zazwyczaj są używane z pojedynczymi wartościami.

    Typy prymitywne w praktyce

    Liczby

    Typ number w TypeScript obejmuje zarówno liczby całkowite, jak i wartości zmiennoprzecinkowe. Nie istnieją oddzielne typy int czy float jak w wielu innych językach, ponieważ sam JavaScript przechowuje zwykłe liczby jako wartości zmiennoprzecinkowe o szerokości 64 bitów.

    let age = 20;
    let price = 26.2;
    let big = 24757689;
    

    Drukowanie ich oraz sprawdzanie typu za pomocą typeof:

    console.log("Age :", age);
    console.log("price:", price);
    console.log(big);
    console.log(typeof(age)); // "number"
    

    Liczby całkowite, liczby dziesiętne oraz duże wartości to wszystko po prostu number, a funkcja typeof zwraca dla każdej z nich wartość "number". Dla liczb całkowitych przekraczających bezpieczny zakres tego formatu zmiennoprzecinkowego JavaScript oferuje oddzielny typ bigint.

    Strony tekstowe

    Strona tekstowa to sekwencja znaków: słowo, zdanie lub dowolny tekst. Można pisać literale tekstowe za pomocą cudzysłowów pojedynczych, podwójnych lub backticków.

    let firstname: string = 'JOHN';
    let lastname: string = "SINGH";
    console.log("Hello", firstname, lastname); // Hello JOHN SINGH
    

    Przekazywanie kilku argumentów oddzielonych przecinkami do console.log wyświetla je rozdzielone spacjami, dlatego w tym przypadku wyświetla się pełne imię. Jednak powiązany z tym wiersz nie robi tego, co wydaje się robić:

    // let greeting: string = "Hello", firstname, lastname; // ❌ this does NOT concatenate
    

    Wewnątrz instrukcji let przecinek służy do oddzielania kilku deklaracji; nie łączy tekstu. Ten wiersz deklaruje trzy oddzielne zmienne: greeting, firstname i lastname, a wartość otrzymuje tylko greeting. Jeśli firstname i lastname już istnieją w tym samym zakresie, TypeScript również zgłosi błąd, że są ponownie deklarowane.

    Zatem przecinki nadają się do wyświetlania kilku wartości, ale aby przechować połączone zdanie w zmiennej, należy użyć literala szablonowego: ciągu znaków umieszczonego w cudzysłowach typu backtick, z miejscami zastępczymi ${} dla wyrażeń.

    let firstname: string = 'JOHN';
    let lastname: string = "SINGH";
    let greeting: string = `Hello ${firstname} ${lastname}`;
    console.log(greeting); // Hello JOHN SINGH
    

    Literale szablonowe to zalecany sposób łączenia tekstu z wartościami. Są czytelniejsze niż łańcuchy konkatencji za pomocą +, a także umożliwiają przechowywanie ciągów znaków na kilku liniach.

    Logiki boolowskie

    Logika boolowska ma dokładnie dwa możliwe wartości: true i false.

    let isStudent: boolean = true;
    let hasJob: boolean = false;
    

    Zapisywanie wartości i sprawdzanie jej typu:

    console.log("Is student?", isStudent); // true
    console.log(typeof (isStudent)); // "boolean"
    

    Logika boolowska steruje każdą warunkową instrukcją w kodzie: wyrażeniami if, warunkami pętli oraz wszędzie tam, gdzie podejmowana jest decyzja typu „tak” lub „nie”.

    Null i undefined

    Zarówno null, jak i undefined oznaczają brak wartości, ale mają różne znaczenia. Zacznijmy od zmiennej, która została zadeklarowana, ale nigdy nie otrzymała wartości:

    let emptyvalue;
    console.log(typeof emptyvalue); // "undefined"
    

    Zmiennej zadeklarowanej, ale nieprzypisanej wartości przypisuje się undefined, co oznacza, że zmienna istnieje, ale w niej jeszcze nic nie zostało przechowane.

    Można również wyraźnie przypisać zmiennym typy null i undefined:

    let emptyvalue: null = null;
    let value: undefined = undefined
    

    Następnie sprawdź, jak każda z nich się wyświetla, wraz ze zmienną o określonym typie, której nie przypisano żadnej wartości:

    console.log(emptyvalue); // null
    console.log(value);      // undefined
    let price: number;
    console.log(price); // undefined - declared, but not yet assigned any value
    

    Różnica w prostych słowach:

    • undefined oznacza, że zmienna istnieje, ale nie ma przypisanej wartości; to domyślny stan pusty.
    • null oznacza, że programista celowo ustawił zmienną na „brak wartości”; to intencjonalna, wyraźna pustość.

    Adnotacja typu taka jak : number ogranicza to, co zmienna może później przechowywać; nie określa ona wartości początkowej. Dlatego w czasie wykonywania price wyświetla wartość undefined. Należy jednak zauważyć, że przy włączonym trybie strict, który jest standardem we współczesnych projektach, TypeScript zgłasza błąd w tej linii console.log(price), ponieważ zmienna jest używana przed przypisaniem wartości. Ta kontrola to kolejny przykład na to, jak kompilator wykrywa błędy, które zwykły JavaScript by pozwolił.

    W codziennym programowaniu rzadko adnotuje się zmienną wyłącznie jako null lub undefined; te przykłady służą jedynie do pokazania, jak zachowują się te dwa stany puste. Znacznie częstszym rozwiązaniem jest typ unii, np. string | null, dla wartości, która może być brakująca.

    Typ any: wyłączenie sprawdzania

    Używaj any rzadko i celowo, ponieważ wyłącza właśnie te sprawdzenia, które wprowadził TypeScript. Zmienna typu any przyjmuje każdy rodzaj wartości i może być przypisana do innego typu według uznania, dokładnie tak jak w zwykłym JavaScript.

    let value: any = "abcd";
    console.log(typeof value); // "string"
    

    Przypisanie jej najpierw do liczby, a potem do wartości logicznej:

    value = 123;
    console.log(typeof value); // "number"
    value = true;
    console.log(typeof value); // "boolean"
    

    Zmienna przechodzi bez żadnych problemów od typu string do liczby, a następnie do wartości logicznej, ponieważ adnotacja poinstruowała kompilatora o nierozpatrywaniu jej.

    Istnieją uzasadnione zastosowania. Gdy naprawdę nie możesz z góry określić typu, na przykład w przypadku nieprzewidywalnych danych zewnętrznych lub podczas migracji krok po kroku z JavaScriptu, gdy pisanie wszystkiego naraz nie jest praktyczne, any pomaga ci kontynuować pracę. Jednak jego szerokie wykorzystanie kosztuje cię funkcję autodopasowywania, błędy w czasie kompilacji oraz zabezpieczenia, które pierwotnie skłoniły do użycia TypeScript. Traktuj go jako ostateczność, a nie nawyk. Dla danych o nieznanej strukturze unknown jest zazwyczaj lepszym wyborem, ponieważ zmusza cię do sprawdzenia wartości przed jej użyciem. Nasz przewodnik dotyczący zastępowania any bezpiecznymi wzorcami typowymi szczegółowo omawia dostępne alternatywy.

    Typy unijne: kontrolowany zbiór opcji

    Typ unii pozwala, aby wartość należała do kilku określonych typów. W odróżnieniu od any, nie przyjmuje wszystkiego, lecz tylko te typy, które wymienisz. „Union” nie jest słowem kluczowym; to nazwa wzorca, zapisywana za pomocą symbolu rury |.

    let id: number | string | boolean;
    

    Alokowanie kolejno każdego dozwolonego typu:

    id = "123abc";
    console.log(id);
    id = 12;
    console.log(id);
    id = true;
    console.log(id);
    

    Zmienna id może przechowywać liczbę, ciąg znaków lub wartość logiczną, a nic więcej. Przydzielanie tablicy, takiej jak [1, 2, 3], jest natychmiast odrzucane, ponieważ tablice nie należą do tej unii. Dzięki temu uzyskujemy elastyczność dokładnie tam, gdzie jest to potrzebne; identyfikatory z rzeczywistego świata czasami przychodzą w postaci liczb, a czasami ciągów znaków, przy jednoczesnym zachowaniu jasnych ograniczeń co do tego, co jest dozwolone. Zanim użyjesz wartości z unii w sposób specyficzny dla danego typu, TypeScript wymaga od ciebie jej zawężenia, na przykład za pomocą sprawdzenia typeof.

    Typowanie funkcji

    Funkcje pakują pojedyncze zadanie w kod, który można ponownie wykorzystać. Definiujesz je raz i wywołujesz w dowolnym miejscu i tyle razy, ile jest to konieczne.

    Funkcja bez parametrów

    Najprostsza wersja ma swoje wartości zaimplementowane bezpośrednio:

    function sum() {
      console.log(10 + 20);
    }
    

    Definicja funkcji nie powoduje jej uruchomienia. Nic się nie dzieje, dopóki jej nie wywołasz:

    sum(); // calling the function → prints 30
    

    To rozdzielenie jest kluczowe: napisz logikę raz i uruchamiaj ją w momencie potrzeby.

    Dodawanie parametrów typowanych

    Funkcje stają się naprawdę wielokrotnie użyteczne, gdy przyjmują parametry – zastępcze wartości, które umożliwiają temu samemu kodowi działanie z różnymi wartościami przy każdym wywołaniu.

    function sum(x: number, y: number) {
      console.log(x + y);
    }
    

    Wywołanie jej z dwoma liczbami:

    sum(10, 20); // prints 30
    

    Teraz sum nie jest już powiązany z wartościami 10 i 20; sum(5, 7) lub sum(100, 1) działają równie dobrze. Oznaczenie : number po każdym parametrze to adnotacja typu tego parametru, dzięki czemu TypeScript odrzuca wywołania takie jak sum("hello", 20) przed uruchomieniem kodu. To właśnie tutaj TypeScript zazwyczaj pokrywa swoje koszty: na granicach funkcji, gdy jedna część programu przekazuje dane drugiej.

    Ta wersja nadal wyświetla swój wynik tylko za pomocą console.log. Nie zwraca nic do wywołującego, co ma znaczenie w kolejnym kroku.

    Ogłaszanie typu zwracanego wartości

    Funkcja, która zwraca swój wynik, jest o wiele bardziej przydatna:

    function sum(x: number, y: number): number {
      return x + y;
    }
    

    Zachowywanie i wykorzystywanie zwróconej wartości:

    let res: number = sum(10, 20);
    console.log(res); // 30
    

    W porównaniu z poprzednią wersją zmieniło się trzy rzeczy:

    • return x + y; zwraca wartość do wywołującego zamiast ją wyświetlać wewnątrz funkcji.
    • : number po liście parametrów, w (x: number, y: number): number, to adnotacja typu zwracanego wartości. Zawiera ona obietnicę, że funkcja zawsze zwróci liczbę, a zwrócenie ciągu znaków spowodowałoby błąd kompilacji.
    • Ponieważ sum teraz zwraca wartość, let res: number = sum(10, 20); może ją przechować do późniejszego użycia: zapisania jej, przekazania innej funkcji lub połączenia z innymi wynikami.

    Funkcja, która zwraca swój wynik, może być łączona z inną logiką; funkcja, która jedynie loguje dane, jest ślepym zaułkiem, którego wynik nie może być ponownie wykorzystany. TypeScript często potrafi samodzielnie odgadnąć typy zwracane, ale ich wyraźne określenie w funkcjach eksportowanych zapobiega przypadkowej zmianie tego, co funkcja zwraca.

    Podsumowanie

    • JavaScript określa typy w czasie wykonywania i w pełni ufa użytkownikowi, co zapewnia elastyczność, ale może prowadzić do ukrytych błędów, takich jak "5" + 3, które dają wynik "53".
    • TypeScript ustala typ każdej zmiennej za pomocą adnotacji lub inferencji i sprawdza go w czasie kompilacji, przed uruchomieniem kodu.
    • TypeScript nie odrzuca wszystkich wątpliwych wyrażeń; łączenie ciągów znaków z liczbami jest dozwolone, a ochrona pojawia się wtedy, gdy powstały ciąg znaków trafia do kodu oczekującego liczby.
    • Błędy typowe domyślnie nie blokują wyjścia; aby zapobiec temu podczas budowania, użyj opcji noEmitOnError lub polecenia tsc --noEmit w procesie CI.
    • Używaj typu any tylko w przypadku rzeczywiście nieznanych danych, preferuj unie i typ unknown dla większej elastyczności, a adnotuj parametry funkcji oraz typy zwracane tam, gdzie dane przekraczają określone granice.

    Wynik wykonywania kodu JavaScript i TypeScript jest identyczny. Zmienia się natomiast sytuacja, gdy odkryjesz błąd: w twoim edytorze, a nie przed użytkownikami.

    Literatura pokrewna