Ten artykuł jest częscią serii

Jeśli nie przeczytałeś jeszcze poprzednich artykułów, to zawsze możesz to nadrobić klikając w przycisk.

Miniatura artykułu

TypeScript - typy zaawansowane

14 minut

Skopiuj link

Data publikacji: 5/21/2023, 9:24:38

Ostatnia aktualizacja: 4/1/2024

Wstęp

W poprzednim artykule omówiliśmy podstawowe typy oferowane przez TypeScript, jednak w prawdziwej aplikacji mamy do czynienia ze znacznie większą liczbą struktur (obiektu, tablice, funkcje, itd.), a jeśli chcemy je poprawnie otypować, to musimy nieco poszerzyć nasze umiejętności.

Oczywiście poznane do tej pory typy, takie jak: string, number, boolean, itd. nadal będą przydatne. Są one podstawowym "budulcem" dla bardziej rozbudowanych typów, a podczas pracy z TypeScriptem będziesz je wykorzystywać codziennie.

Obiekty i aliasy

Obiekty w JavaScript są z natury mutowalne. Możemy w dowolny sposób zmieniać ich właściwości, nadpisywać je, dodawać nowe i usuwać istniejące. Oczywiście istnieje kilka rozwiązań tego problemu, w zależności od potrzeb możemy skorzystać z takich metod jak Object.seal(), Object.freeze() lub nawet obiektu Proxy, w celu szczegółowej kontroli nad danym obiektem. TypeScript nie zastępuje tych rozwiązań, ponieważ jego działanie ogranicza się do momentu kompilacji. Unikniemy jednak przypadkowych zmian struktury lub typu właściwości obiektu w czasie tworzenia aplikacji i w jasny, czytelny sposób określimy jego strukturę.

JavaScript nie widzi żadnego problemu, ze zmianą właściwości z typu string, na number. Bez problemu można również dodać do obiektu właściwość, której nie było tam na początku.

Jeżeli spróbujemy tej samej operacji w TypeScript, to otrzymamy błąd, informujący nas o tym, że nie można zmienić typu właściwości, ani dodać nowej.

Oczywiście nic nie stoi na przeszkodzie, żeby wewnątrz obiektu dodać kolejny obiekt. Zapis ten wygląda niemal identycznie jak w JavaScript, jednak zamiast wartości opisujemy jedynie typ - strukturę obiektu.

Być może zauważyłeś już jeden problem z powyższym zapisem. W przypadku prostych obiektów jest on w miarę czytelny, co jednak, jeżeli nasz obiekt ma wiele właściwości?

Alias typu

Działają podobnie do zmiennych, jednak zamiast wartości przechowują jedynie typ. Nie można korzystać z nich w czasie runetime'u - zostają usunięte podczas kompilacji TypeScriptu do JavaScriptu.

Dzięki zastosowaniu aliasów możemy wielokrotnie używać ten sam typ bez konieczności tworzenia go od nowa za każdym razem. Kolejnym plusem jest zwiększenie czystości kodu. Zamiast deklarować typ w tej samej linii, co zmienną możemy rozdzielić te dwie czynności.

Za pomocą słowa kluczowego type utworzyliśmy alias o nazwie Person, następnie otypowaliśmy zmienną person z użyciem aliasu. Czynność ta wygląda tak samo jak dla każdego innego typu.

Oczywiście tworzenie aliasu dla typów prostych, takich jak boolean, number, null, itd. nie ma większego sensu. Wykorzystuj aliasy przy bardziej rozbudowanych typach, lub takich, które planujesz wykorzystać wielokrotnie. Nie ma jasnej granicy, kiedy należy zastosować alias, a kiedy nie. Najlepszym doradcą jest tutaj doświadczenie. W kolejnych przykładach będziemy używać zdobytej wiedzy, żeby nieco się z nią oswoić.

Nazewnictwo

Warto pamiętać o kilku dobrych praktykach w czasie nazywania aliasu:

  • Nazwę zawsze zaczynaj z wielkiej litery

  • Zawsze stosuje liczbę pojedynczą w nazwie (np. User zamiast Users). Jedynym wyjątkiem są tu aliasy zawierające typ, który opisuje tablicę lub tuple (jeżeli nie spotkałeś się wcześniej z tym pojęciem, to w następnym akapicie znajdziesz jego wyjaśnienie)

  • Nie dodawaj prefixów do nazw np. wielkiej litery "i" przed nazwą interface'u (dowiesz się o nich później), lub wielkiej litery "t" przed nazwą typu. Zatem wybieraj nazwę User zamiast IUser

  • Dla kompletności wspomnę również o parametrach generycznych, chociaż na tym etapie, nie musisz się nimi przejmować. Przeważnie nazywa się je pojedynczymi literami np. T lub U. Jeżeli jednak wybierzesz inną konwencję, to dobrą praktyką jest prefixowanie ich nazwy tymi właśnie literami (np. TData zamiast Data). Inni programiści, będą nie pierwszy rzut oka widzieć, że jest to parametr generyczny.

Tablice

W TypeScript istnieją dwa typy tablic:

  • klasyczne tablice znane nam z JavaScript. Nie mają sztywno określonej długości i są nieco mniej restrykcyjne jeśli chodzi o dozwolone wewnątrz nich typy danych (wyjaśni się to za chwilę)

  • tuple (krotki) - czyli tablice o ograniczonej długości oraz "sztywno" określonych typach danych.

Zacznijmy od tego w jaki sposób utworzyć typ tablicowy.

W przypadku zwykłej tablicy mamy do dyspozycji dwa zapisy (ściśle rzecz biorąc jest ich więcej, ale korzystanie z jednego z dwóch poniższy jest zdecydowanie najlepszą praktyką). Jeden z nich wykorzystuje typ generyczny Array (więcej o typach generycznych w przyszłych artykułach - na tym etapie nie musisz rozumieć jak one działają), natomiast drugi wykorzystuje zapis [ ]. Oczywiście w miejsce string możemy wstawić dowolnym typ lub jego alias.

W powyższym przkładzie najpierw tworzymy alias User a następnie używamy go do otypowania zmiennej user i inicjalizujemy ją odpowiednią wartością, pasującą do typu.

W przypadku tupli sprawa wygląda nieco inaczej. Nie można tu wykorzystać składni Array<type>, jest ona zarezerwowana dla tablic. Musimy więc skorzystać z [ ]. Jednak zamiast określać jakiego typu spodziewamy się w całej tablicy, sprecyzujemy konkretne jaki typ ma znaleźć się pod danym indeksem.

Pamiętaj, że TypeScript nie zapewnia bezpieczeństwa w czasie działania aplikacji (runtime). W związku z tym nadal możliwa jest modyfikacja stworzonej tupli - dla JavaScriptu jest to zwykła tablica i nic nie stoi na przeszkodzie, żeby ją zmienić.

Problem ten można częściowo rozwiązać za pomocą słowa kluczowego readonly dodanego przed typem opisującym tuple. Jest to nieco bardziej zaawansowany temat, więcej zastosowań tego modyfikatora poznasz w kolejnych artykułach.

Minusem takiego podejścia jest fakt, że cała utworzona tupla jest traktowana przez TypeScript jako niemodyfikowalna. Nie tylko nie można zmienić jej długości, ale również nie można zastąpić istniejących już elementów. Postaramy się rozwiązać ten problem, kiedy już poznamy typy generyczne.

Unie

Unie to połączenie co najmniej dwóch innych typów. Najłatwiej myśleć o nich, jak o logicznym OR ( || ) dla typów. Sam zapis wygląda nawet podobnie - jest to po prostu |.

W przypadku tablic sprawa się nieco komplikuje. W zależności od zapisu unia może oznaczać dwa zupełnie różne typy.

Oczywiście możesz zastosować więcej niż jeden operator | w jednym typie. W przykładzie poniżej używam większości poznanych do tej pory typów, do stworzenia znacznie bardziej rozbudowanego przypadku.

Warto zapamiętać, że unię można zastosować wszędzie tam, gdzie można zastosować typ.

Jeżeli czytałeś poprzednie artykuły, to być może pamiętasz, że należy wystrzegać się typu any, i używać go z rozwagą. Wspominam o tym, ponieważ każda unia zawierająca any, automatycznie staje się typem any.

Dlaczego? Ponieważ do any możemy przypisać wszystko, zatem jakiekolwiek inne ograniczenia, które wynikają z innych elementów unii, nie mają sensu - i tak będzie można przypisać tam wszystko.

Będąc przy uniach muszę wspomnieć o jeszcze jednym typie, o którym do tej pory nie było mowy i który nie występuje w JavaScript, a jego zachowanie jest dość specyficzne, kiedy występuje w unii.

Do czego służy Never?

Dla początkujących może być trudny do zrozumienia. Jest to typ, do którego nie możemy nic przypisać. Od razu nasuwa się pytanie po co nam w ogóle coś takiego i co właściwie można z tym zrobić?

Istnieją dwa główne zastosowania dla typu never:

  • opisanie typu zwrotnego z funkcji, która nigdy nie zwraca żadnej wartości. Przykładem może być funkcja, która zawsze kończy się rzuceniem błędu. W kolejnym akapicie omawiamy jak otypować funkcję, dlatego ten zapis może nie być do końca zrozumiały.

  • wynik działania innego typu - jest to temat znacznie bardziej zaawansowany, żeby go zrozumieć, musimy najpierw poznać typy warunkowe oraz generyczne.

Jeżeli nadal nie jesteś pewny, gdzie i jak powinieneś wykorzystać never, to zdecydowanie nie powinieneś się tym przejmować. Stanie się to znacznie bardziej zrozumiałe, gdy przejdziemy do typów warunkowych.

To co warto zapamiętać, to zachowanie tego typu w połączeniu z dowolną unią.

Nawet jeżeli dodamy never do unii, to TypeScript automatycznie go z niej usunie (w kodzie nadal będzie widoczny). Dzieje się tak, ponieważ nie można przypisać do niego żadnej wartości, więc jego obecność w unii nie ma żadnego sensu.

Oczywiście nie ma żadnego powodu by stosować zapis taki jak w przykładzie wyżej. Typ never zazwyczaj znajduje się w unii z powodu działania innego typu (warunkowego lub generycznego).

Funkcje

Za pomocą TypeScriptu możemy opisać nie tylko zmienne, ale również parametry (dzięki czemu nie przekażemy omyłkowo błędnej wartości) i typ zwracany z funkcji. Są jednak pewne różnice w zapisie w zależności od wybranej konstrukcji.

Deklaracja funkcji

Parametry funkcji opisujemy za pomocą składni nazwaParametru: typ.
Wartość zwracaną z funkcji opisujemy bezpośrednio po liście parametrów ( ... ): number.

W tym momencie warto przypomnieć sobie o inferencji. O ile dla parametrów inferencja nie jest możliwa, to dla typu zwrotnego już tak. Gdy tylko jest to możliwe TypeScript spróbuje domyślić się, jaki typ zwraca stworzona przez nas funkcja (podobnie jak przy zmiennych, większość edytorów kodu wyświetli wszystkie potrzebne informacje po najechaniu myszką na deklarację funkcji lub wyrażenie funkcyjne).

Czy w takim razie powinniśmy w ogóle opisywać zwracany typ? A może opisywać go tylko, jeżeli inferencja nie działa zgodnie z oczekiwaniami?

Każde podejście ma swoje plusy i minusy, przedstawię dwa najpopularniejsze i opowiem o nich krótko:

  • Zawsze opisujemy zwracany typ.
    Minusem tego podejścia jest nieco więcej kodu, który musimy napisać (podobnie jak przy zmiennych). Niektórzy twierdzą również, że takie podejście zaciemnia kod i utrudnia jego odczytanie.

    Ja należę do drugiej grupy - widzę więcej plusów tego rozwiązania niż minusów. Przede wszystkim, gdy będziemy wprowadzać zmiany w funkcji lub refactorować kod, mamy pewność, że nie zwrócimy przypadkowo błędnego typu (TypeScript nas przed tych ochroni i jasno zakomunikuje, że zwracana wartość nie pasuje do podanego typu).

    Po drugie, już na pierwszy rzut oka widać, co zwraca dana funkcja, dzięki czemu nie musimy analizować jej kodu ani nawet najechać na nią kursorem.

  • Opisujemy tylko wtedy, gdy inferencja nie daje sobie rady.
    Głównym plusem jest tutaj mniej kodu. Nie musimy tworzyć dodatkowych typów i zastanawiać się jak ich opisać. Wszystko to odbywa się jednak kosztem bezpieczeństwa.

Wyrażenie funkcyjne

W przypadku wyrażeń funkcyjnych mamy do dyspozycji jeszcze jedną możliwość ich opisania (możemy także wykorzystać sposób użyty w deklaracji funkcji). Zamiast po kolei typować argumenty a następnie zwracany typ, możemy za jednym zamachem utworzyć alias i opisać całą funkcję.

Za pomocą aliasu CreateUser opisujemy wyrażenie funkcyjne. Zauważ, że w tym przypadku typ, która zwraca funkcja następuje po => User (w przypadku deklaracji funkcji, był to zapis ((): User) dokładnie tak, jak w wyrażeniu funkcyjnym.

Typ void

Będąc przy funkcjach trzeba wspomnieć o jeszcze jednym typie z którym do tej pory nie mieliśmy styczności. Jest on niemal wyłącznie używany do opisania funkcji, która nie zwraca żadnej wartości (a więc zgodnie z zachowaniem JS zwraca undefined), lub wyraźnego zaznaczenia, że wartość zwrócona nie ma dla nas żadnego znaczenia.

Może się wydawać, że w takim razie powinniśmy otypować zwracaną wartość jako undefined, w końcu tak naprawdę właśnie to zwraca nasza funkcja. Jeżeli jednak tak zrobimy, to TypeScript będzie od nas wymagać jawnego zwrócenia tej wartości. Musielibyśmy dodać na końcu return undefined.

W takim razie kiedy używać void?

  • Jeżeli nie używamy w funkcji słowa kluczowego return i funkcja ta nie kończy się zawsze wyjątkiem (wtedy należy użyć typu never)

  • Jeżeli używamy słowa kluczowego return wyłącznie do przerwania działania funkcji (po return nie występuje żadna wartość).

  • Kiedy za pomocą typu chcemy dać znać, że wartość zwrócona z tej funkcji nie ma żadnego znaczenia w kontekście, w którym będzie użyta. Poniższy przykład ilustruje taką sytuację

Słowo na koniec

Jeżeli nigdy wcześniej nie miałeś styczności z zagadnieniami poruszonymi w artykule, to koniecznie poćwicz je we własnym zakresie, a dopiero później przystąp do dalszej nauki, chwila przerwy pomiędzy kolejnymi tematami i czas na ich przyswojenie to klucz do skutecznej nauki. Jeżeli coś wydaje Ci się na tym etapie niejasne, to nie wahaj się zajrzeć do dokumentacji (płynna praca z wszelkimi rodzajami "doksów", to również ważny aspekt, który warto ćwiczyć) lub zapytać w komentarzu albo prywatnej wiadomości do mnie.

Avatar: Wojciech Rygorowicz

Software Engineer / Fullstack developer

Wojciech Rygorowicz

wojciech.rygorowicz@gmail.com

Podziel się na

Dodaj komentarz

Komentarze (0)

Brak komentarzy

Jeżeli zainteresował Cię ten artykuł koniecznie przeczytaj inne artykuły z tej serii