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ń!