Skocz do treści

Już wkrótce odpalamy zapisy na drugą edycję next13masters.pl. Zapisz się na listę oczekujących!

Kurs TypeScript – część 3

Kontynuuję serię wpisów na temat TypeScript. Pisząc poprzedni post złapałem się na tym, że dobijałem już do blisko 1800 słów. Uznałem, że to zdecydowanie za dużo jak na jeden wpis i stąd tak szybko kolejna część ;) W tym odcinku mówię o zaawansowanych typach, aliasach i literałach. Dodatkowo poruszam również temat inferencji typów. Zapraszam do czytania :)

Ten artykuł jest częścią 4 z 4 w serii TypeScript.

Zdjęcie Michał Miszczyszyn
JavaScript26 komentarzy

Zakładam, że czytelnicy są zaznajomieni z JavaScriptem, a w szczególności z konceptami dodanymi w ECMAScript 2015 takimi jak class oraz let i const. Jeśli jednak coś jest niejasne to chętnie odpowiem na pytania w komentarzach.

Zaawansowane typy

W poprzednim wpisie przy okazji przykładu z tablicą zwierząt, użyłem czegoś co nazwałem union type. Dla przypomnienia wyglądało to tak:

const animals:Array<Horse|Dog|ShibaInu|Poodle> = [];  

Jest to jeden z tzw. typów zaawansowanych. Omówmy je teraz szybko:

Union type

Union type pozwala na opisanie typu, który jest jednym typem lub drugim typem. Przykładowo możemy stworzyć funkcję, która jako argument przyjmuje number lub Date:

function formatDate(date:number|Date) {  
    if (typeof date === "number") {
        // tutaj TypeScript już wie, że data jest liczbą!
        date = new Date(date);
    }}

Intersection type

Intersection type jest blisko związany z union type, ale pozwala na opisanie typu, który ma cechy kilku typów na raz. Najczęściej wykorzystywany jest z interfejsami. Korzystając z interfejsów z poprzedniej części, wyobraźmy sobie, że chcemy stworzyć funkcję, która oczekuje obiektu będącego na raz Serializable i Drawable:

function mojaFunkcja(obiekt:Serializable & Drawable) {  
    // obiekt na pewno ma metody toJSON i draw!
}

Aliasy typów

TypeScript pozwala na definiowanie aliasów typów. Możemy na przykład zdefiniować typ Name, który będzie stringiem:

type Name = string;

class User {  
    firstName:Name;
}

Dzięki temu potencjalna zmiana jednego typu na drugi będzie łatwiejsza. Dodatkowo poprawia to również czytelność kodu i łatwość jego tworzenia. Spójrzmy na przykład:

class Process {  
    flag: boolean;
}

Intuicyjnie, pole o nazwie flag mogłoby być typu boolean, enum, number lub string i każdy z tych typów mógłby mieć sens. Możliwe jest również, że kiedyś w przyszłości będziemy potrzebować zmienić boolean na string (z powodu daleko idącej refaktoryzacji kodu). Jeśli pole flag zadeklarujemy po prostu jako boolean, taka zmiana będzie znacznie trudniejsza. Oprócz klasy będziemy musieli prawdopodobnie również zmodyfikować funkcje, które będą miały na stałe wpisany typ boolean:

isProcessFlagValid(flag:boolean) {}

Dobrym rozwiązaniem tego problemu jest stworzenie nowego typu dla tego pola:

type ProcessFlag = boolean;

class Process {  
    flag: ProcessFlag;
}

isProcessFlagValid(flag:ProcessFlag) {}

Dzięki temu nie musimy pamiętać dokładnie jakiego typu jest to pole w klasie, a w razie potrzeby jego zmiany, łatwo znajdziemy wszystkie miejsca, w których wymagana jest modyfikacja, po prostu szukając odwołania do typu ProcessFlag.

Alias funkcji

Możliwe jest również zdefiniowanie typu oznaczającego funkcję. Jest to bardzo przydatne przy opisywaniu definicji callbacków przekazywanych do funkcji. Wyobraźmy sobie, że tworzymy bibliotekę, w której jedna z funkcji oczekuje, że inny programista przekaże jako argument funkcję, która jako argument przyjmuje obiekt typu User i zwraca true lub false jeśli użytkownik jest poprawny.

// nasza biblioteka
type UserCallback = (user:User) => boolean;

function fetchUser(callback:UserCallback) {}

// kod użytkownika
function fetchUserCallback(user:User) {  
    if (user.name === 'Michal') {
        return true;
    }
    return false;
}

fetchUser(fetchUserCallback);  

Dzięki zadeklarowaniu typu callback w powyższy sposób, jeśli użytkownik spróbuje przekazać nieprawidłową funkcję jako argument to otrzyma błąd:

// blad!
fetchUser((user:User) => {  
    // zapomnialem zwrocic true lub false
});

String Literal Type

Często zdarza mi się potrzeba zadeklarowania tego, że funkcja jako argument może przyjąć nie tyle typ, co konkretne wartości. Przykładowo tworzymy funkcję, która pobiera pewne rekordy z bazy danych. Chcemy dać użytkownikom możliwość grupowania tych danych po sekundach, minutach, godzinach lub dniach. Rozwiązaniem, które może przyjść do głowy to zadeklarowanie, że funkcja przyjmuje string, a następnie sprawdzenie czy ten string ma odpowiednią wartość:

function groupRecords(groupBy:string) {  
    if (groupBy === "second" || groupBy === "minute" || groupBy === "hour" || groupBy === "day") {} else {
        // blad!
    }
}

Istniej jednak lepsze rozwiązanie od tego. Zdefiujmy sobie typ GroupBy:

type GroupBy = 'second'|'minute'|'hour'|'day';

function groupRecords(groupBy:GroupBy) {}

Dzięki temu kompilator sam sprawdzi (w miarę możliwości!), czy podana wartość jest prawidłowa. String literal type świetnie sprawdzi się też jako flaga wspomniana w poprzednim akapicie.

Inferencja typów

O inferencji typów wspominałem już w swoim innym wpisie na temat TypeScripta: TypeScript z node.js?. Ponownie wykorzystam przykład z tamtego wpisu:

function fn(b:boolean) {  
    if (b === true) {
        return 1;
    } else {
        return 2;
    }
}

Ta funkcja zwraca liczbę i jest to ewidentne. TypeScript również jest tego pewien i dlatego nie musimy tutaj podawać zwracanego typu. TypeScript inferuje, że jest to number:

const liczba:number = fn(true); // dziala!  

Możemy pójść nawet o krok dalej. Skoro fakt, że fn zwraca liczbę jest oczywisty, to czy w ogóle konieczne jest deklarowanie liczba:number? Nie!

const liczba = fn(true); // dziala!  

Ponownie TypeScript inferuje, że zmienna liczba jest typu number. Ten kod oraz poprzedni są sobie całkowicie równoważne.

W sytuacjach, które są dwuznaczne TypeScript wyświetli błąd i zmusi do zadeklarowania odpowiedniego typu:

function fn2(b:boolean):string|number {  
    if (b === true) {
        return 1;
    } else {
        return 'lol';
    }
}

Bez deklaracji string|number otrzymalibyśmy błąd:

No best common type exists among return expressions.

Inferencja typów działa również gdy od razu przypisujemy do zmiennej lub stałej wartości:

const tab1 = [0, 1, 'lel']; // Array<number|string>  
const tab2 = [0, null]; // Array<number>  
const tab3 = [new Dog('leszek'), new Horse('rafal')]; // Array<Dog|Horse>  

Dwie pierwsze linijki są chyba oczywiste. Jednak w ostatnim przypadku (odwołuję się tutaj do kodu z poprzedniego wpisu) klasy Dog i Horse mają wspólną klasę bazową Animal, a więc moglibyśmy przecież oczekiwać, że tablica tab3 będzie typu Animal! Tak się jednak nie dzieje i TypeScript jasno informuje o tym w swojej dokumentacji. Aby otrzymać taki efekt w tablicy musiałaby się znaleźć instancja klasy Animal – w przeciwnym wypadku musimy ręcznie zadeklarować typ. To wszystko dla naszego dobra, uwierzcie mi na słowo :)

const tab3:Array<Animal> = [new Dog('leszek'), new Horse('rafal')]; // Array<Animal>  

Dzięki rozbudowanemu mechanizmowi inferencji typów kod staje się o wiele bardziej zwięzły i prostszy do pisania bez utraty zalet statycznego typowania. Wiele długich i formalnych definicji możemy po prostu pominąć. Inferencja typów jest elementem praktycznie każdego nowoczesnego języka programowania, między innymi C#, Go, C++, Haskell, Swift czy Rust.

Podsumowanie

I to by było na tyle w tym wpisie! Dowiedzieliśmy się całkiem sporo na temat typów zaawansowanych: union type i intersection type. Ponadto nauczyliśmy się definiować aliasy typów oraz funkcji. Doceniliśmy również zwięzłość kodu, jaką daje nam inferencja typów :)

W kolejnym wpisie z serii wykorzystamy zdobytą wiedzę i spróbujemy przepisać prosty widget napisany w JS na TypeScript. Zachęcam do komentowania i zadawania pytań!

👉  Znalazłeś/aś błąd?  👈Edytuj ten wpis na GitHubie!

Autor