świstak.codes

O programowaniu, informatyce i matematyce przystępnym językiem

Teoria zbiorów a TypeScript

Omawiając ostatnio algebrę zbiorów, przedstawiłem jej zastosowanie w najbardziej oczywisty dla programistów sposób — na strukturach danych zbiorów i tablic. Jak się jednak okazuje, zagadnienia z niej mają znacznie więcej zastosowań w informatyce. Tym razem pokażę, jakie przełożenie ma ten obszar logiki matematycznej na język programowania TypeScript, a dokładniej na jego system typów. Innymi słowy, nie zastosujemy logiki w wykonywalnym kodzie programu, ale w technicznym opisie tego, co my w ogóle programujemy. A patrząc z jeszcze innej strony — poznamy wycinek teorii typów w praktyce.

Uwaga wstępna

Artykuł zostanie poświęcony rozpatrywaniu języka TypeScript od strony logiki matematycznej, więc zakładam, że znasz jej podstawy. Jeśli nie, zapraszam do moich innych artykułów, gdzie opisuję:

W kontekście tego tekstu szczególnie przydadzą Ci się pojęcia z teorii zbiorów.

Czym jest TypeScript?

Z racji tego, że mój blog nie skupia się wokół konkretnych obszarów informatyki ani języków programowania, zakładam, że możesz nie wiedzieć, czym jest TypeScript. W tym akapicie zrobię krótkie wprowadzenie do poziomu takiego, jaki może się przydać do zrozumienia artykułu. Jeśli wiesz, czym jest TypeScript, możesz spokojnie przejść do Typy proste jako zbiory.

TypeScript to, najkrócej mówiąc, język programowania bazujący na JavaScript, dodający do niego silne typowanie. Pokazując najprościej, gdy JavaScript pozwala na coś takiego:

var a = 21;
a = 'trzydzieści siedem';
a === true;

to ten sam kod w TypeScript zostanie uznany za błędny, ponieważ pierwsza linijka będzie implikować, że zmienna jest typu liczbowego, więc nie możemy przypisać do niej ciągu znaków ani przyrównać do wartości logicznej. Możesz to zobaczyć na własną rękę w TypeScript Playground, czyli w aplikacji, dzięki której możemy sprawdzać, jak działa TypeScript. Będę stosować linki do niej do zobrazowania wszystkich przykładów w artykule.

TypeScript z racji tego, że musi opisać słabo typowany język, jakim jest JavaScript, daje bardzo dużą dowolność w definiowaniu typów. Dzięki temu możemy pokryć najdziwniejsze przypadki, do których zmuszają nas sytuacje w kodzie aplikacji, a których nie spotkalibyśmy w językach programowania takich jak C++, C# czy Java. Pominę opis, po co to robić, ponieważ wiele osób się już na ten temat wypowiedziało w Internecie (w tym ja, sprawdź Publikacje). W kontekście artykułu interesuje nas, co system typów ma wspólnego ze zbiorami, albo nawet ogólniej — z logiką matematyczną.

Jeszcze najkrócej mówiąc — czym jest typowanie? Jest to inaczej system typów, czyli klasyfikacja tego, co programujemy (zmiennych, funkcji itd., czyli wyrażeń) w zależności od rodzaju wartości. Typ definiuje, czym jest dana wartość i jakie operacje można na niej wykonać. W dużym uproszczeniu: o słabym typowaniu mówimy wtedy, gdy typ wyrażenia możemy w dowolnym momencie zmienić, a o silnym, gdy nie ma takiej możliwości.

Podkreślam słowo uproszczenie, bo mówiąc o systemach typów, podział nie jest taki prosty i zero-jedynkowy, a pojęć jest dużo więcej. Jednak na potrzeby tego, co chcę tutaj opisać, taki skrót zupełnie wystarczy.

Typy jako zbiory

Typy proste

Zacznijmy od najprostszych typów, które są dostępne w TypeScript, czyli: string (ciąg znaków), number (typ liczbowy), boolean (typ logiczny, boolowski). Na razie skupmy się na tych. Pomyślmy o nich jako o zbiorach:

  • Typ string to zbiór wszystkich możliwych kombinacji znaków układających się w ciągi.
  • Typ number to zbiór liczb wymiernych (w JavaScript wszystkie liczby to 64-bitowe liczby zmiennoprzecinkowe).
  • Typ boolean to zbiór składający się z dwóch wartości: true i false.

Skoro reprezentujemy typy jako zbiory, musimy w jakiś sposób opisać uniwersum (zbiór wszystkich możliwych wartości) i zbiór pusty. TypeScript nam to oferuje:

  • any i unknown to uniwersum (Ω\Omega). Różnica między nimi polega na tym, że unknown wymusza na programiście zawężenie typu, a any nie, jednak z punktu widzenia teorii zbiorów są tym samym. Dla uproszczenia dalej w artykule pominę unknown (chociaż w kodzie powinieneś/powinnaś stosować go zamiast any).
  • never to zbiór pusty. Tutaj jako zbiór pusty nie rozumiemy wartości null (brak wartości) czy undefined (niezdefiniowanej wartości), bo te dla TypeScript są oddzielnymi typami. never opisuje sytuację, gdy nie da się sprecyzować żadnej wartości, nawet jej braku, czyli czegoś, co nigdy nie powinno się zdarzyć.

Nawiązując do powyższego, mamy jeszcze dodatkowe dwa typy:

  • Typ null, który zawiera tylko wartość null — czyli brak wartości.
  • Typ undefined, który opisuje brak zdefiniowanej wartości — czyli undefined.

Różnica jest taka: null to konkretna wartość symbolizująca brak wartości. undefined oznacza, że zmienna nie ma przypisanej wartości. never to natomiast sytuacja, gdy żadna wartość nie może zostać przypisana, ponieważ nie istnieje wartość, którą dałoby się do danej zmiennej przypisać. Wiem, brzmi to skomplikowanie, ale różnica między null i undefined pochodzi z JavaScript, nie została wymyślona przez twórców TypeScript.

W uproszczeniu świat typów TypeScripta moglibyśmy opisać następującym diagramem Venna:

Prostokąt opisany any/unknown, wewnątrz którego zawarte są nieprzecinające się elipsy: number, string, boolean, null i unknown.
Diagram przedstawiający zbiory reprezentujące TypeScriptowe typy i powiązania między nimi.

Poniżej możesz zobaczyć zobrazowanie tego wszystkiego w praktyce:

var tekst: string;
var liczba: number;
var logiczna: boolean;
var wszystko: any;
var nic: never;

// prawidłowe przypisania wartości
tekst = 'coś';
liczba = 37;
logiczna = false;

tekst = 37; // Type 'number' is not assignable to type 'string'.
liczba = false; // Type 'boolean' is not assignable to type 'number'.
logiczna = 'coś'; // Type 'string' is not assignable to type 'boolean'.

// any może mieć każdą wartość
wszystko = 37;
wszystko = 'coś';
wszystko = false;

// never nie może przyjąć żadnej wartości
nic = 37; // Type 'number' is not assignable to type 'never'.
nic = 'coś'; // Type 'string' is not assignable to type 'never'.
nic = false; // Type 'boolean' is not assignable to type 'never'.
// nawet pustej i niezdefiniowanej wartości
nic = null; // Type 'null' is not assignable to type 'never'.
nic = undefined; // Type 'undefined' is not assignable to type 'never'.

Powyższy kod możesz sprawdzić w praktyce na TypeScript Playground.

Typy literal

Skoro każdy typ prosty jest zbiorem, to czy możemy dostać się do ich konkretnych wartości i w ten sposób zdefiniować własny typ? Jak najbardziej tak. Służą do tego typy literal (typ dosłowny, aczkolwiek nie spotkałem się nigdy z takim tłumaczeniem, dlatego zostanę przy angielskiej nazwie), czyli użycie konkretnej wartości jako typu zmiennej. Wygląda to następująco:

// Uwaga! Definicja typu to co innego niż przypisanie wartości!
// W tym miejscu jedynie definiujemy typ
var liczba: 21;
var tekst: 'tekst';
var logiczna: false;

// prawidłowe przypisania wartości
liczba = 21;
tekst = 'tekst';
logiczna = false;

// błędne przypisania, mimo że dalej jest to wartość z tego samego zbioru
liczba = 37; // Type '37' is not assignable to type '21'.
tekst = 'coś'; // Type '"coś"' is not assignable to type '"tekst"'.
logiczna = true; // Type 'true' is not assignable to type 'false'.

Możesz przetestować kod na TypeScript Playground.

Dlaczego tak jest? Skupmy się na liczbach. Mimo że mamy typ number, to ustawiając jako typ wartość 21, mówimy kompilatorowi, że jest to jedyna dopuszczalna wartość. Na chłopski rozum chciałoby się powiedzieć, że 21 to liczba i 37 to także liczba, więc powinno dać się przypisać, jednak stosując literal, zawężamy się do wskazanego podzbioru, więc:

21number21{21}    {21}number37number37{37}    {37}number37{21}    {37}{21}\begin{align*} 21 \in number \land 21 \in \{21\} &\implies \{21\} \subseteq number \\ 37 \in number \land 37 \in \{37\} &\implies \{37\} \subseteq number \\ 37 \notin \{21\} &\implies \{37\} \nsubseteq \{21\} \end{align*}
Elipsa z napisem number, a w niej dwie nieprzecinające się elipsy 37 i 21
Typy 37 i 21 są podzbiorami typu number, jednak nie mają ze sobą części wspólnej.

Oczywiście wartość zmiennej o typie 21 możemy wciąż przypisać do nadzbioru, czyli number:

var liczba: 21;
liczba = 21;

// deklarujemy zmienną o ogólniejszym typie
var liczba2: number;
liczba2 = liczba; // prawidłowe przypisanie

// sprawdźmy w drugą stronę, nieznając wartości zmiennej
// słowo kluczowe declare mówi kompilatorowi,
// że ta zmienna na pewno już istnieje i ma wartość
declare var liczba3: number;
liczba = liczba3; // Type 'number' is not assignable to type '21'.

Ponownie, możesz to sprawdzić na TypeScript Playground.

Suma zbiorów

Teraz możesz sobie pomyśleć: fajnie, że definiujemy podzbiory, ale co mi z podzbioru zawierającego tylko jedną wartość? W tym miejscu wprowadźmy jedną z podstawowych operacji na zbiorach — sumę. W TypeScript zapisuje się ją operatorem |.

Mówiąc o typach literal, możemy za pomocą sumy zbiorów tworzyć typy obsługujące nieco więcej wartości. I to niekoniecznie stanowiące podzbiór tylko jednego typu:

var liczba: 37 | 21;
var liczbaLubTekst: 999 | 'tekst';

// prawidłowe przypisania
liczba = 37;
liczbaLubTekst = 999;
liczba = 21;
liczbaLubTekst = 'tekst';

Sprawdź sam(a) na TypeScript Playground.

Czyli w przypadku typu zmiennej liczbaLubTekst osiągnęliśmy coś, co moglibyśmy zobrazować następującym diagramem:

Nieprzecinające się elipsy z napisami number i string, a na nich narysowana druga elipsa z napisem '999 | tekst'.
Typ `999 | "tekst"` przecina się ze zbiorami string i number, aczkolwiek nie jest podzbiorem żadnego z nich.

Sumować możemy oczywiście każdy możliwy zbiór (typ). Tym samym możemy robić następujące rzeczy:

var liczbaLubTekst: number | string;
var logicznaLubPusta: boolean | null;

// poniższe przypisania są prawidłowe
liczbaLubTekst = 21;
liczbaLubTekst = 'tekst';
logicznaLubPusta = true;
logicznaLubPusta = null;

// można również przypisywać wartości z dowolnych podzbiorów
declare var wybraneLiczby: 37 | 21;
declare var wybraneLiczbaTekst: 999 | "tekst";
// poniższe przypisania są również prawidłowe
liczbaLubTekst = wybraneLiczby;
liczbaLubTekst = wybraneLiczbaTekst;

// nie można jednak przypisać typów, które jedynie się przecinają
declare var rozne: 21 | false | "cos";
liczbaLubTekst = rozne; // Type 'boolean' is not assignable to type 'string | number'.
logicznaLubPusta = rozne; // Type '21' is not assignable to type 'boolean | null'.

Możesz przetestować powyższy kod na TypeScript Playground.

A jakie są najczęstsze zastosowania tego w praktyce?

  • Sumami typów literal zwykle zawęża się możliwy przedział wartości, jakie może przyjąć dana zmienna, coś na wzór typów wyliczeniowych (enum) w innych językach programowania.
  • | undefined służy adnotacji, że dana zmienna (np. argument funkcji) jest opcjonalna. Jest to inne zastosowanie niż | null, który mówi, że możemy jawnie wskazać brak wartości. Jednak w praktyce wielu programistów JavaScript utożsamia null i undefined, więc trzeba pisać | null | undefined.
  • Ze względu na specyfikę JavaScript nie ma tutaj mechanizmu przeciążeń funkcji i metod z wersjami z różnymi typami argumentów czy też ich różną ilością. Można to zdefiniować na poziomie TypeScript, ale koniec końców trzeba potem napisać najbardziej ogólną wersję biorącą pod uwagę wszystkie przypadki — wówczas unie typów są bardzo przydatne. A suma typu string | number | boolean | null jest zdecydowanie bardziej zawężająca niż any.

Typy obiektowe

Skoro zobaczyliśmy, jak się zachowują typy proste i jak się przekładają na zbiory, wprowadźmy do tego wszystkiego typy obiektowe. Są to typy, w których definiujemy pary klucz-typ. Jeśli znasz koncepcje klas czy interfejsów z obiektowych języków programowania, to jest to coś podobnego — określamy, jakiego typu może być pole o danej nazwie. Różnica jest tylko taka, że takie JavaScriptowe obiekty są bardziej zbliżone do słowników (map) — nie ma tu konstruktora, tylko jest to worek, w którym trzymamy pary klucz-wartość. To, co daje TypeScript, to jawne określenie co ten worek może posiadać.

// definiujemy typ za pomocą słowa kluczowego `type`
type Obiekt = {
    poleTekstowe: string;
    poleNumeryczne: number;
    nieobowiazkoweLogiczne?: boolean;
    // ? wskazuje nieobowiązkowe pole, któremu nie musimy przypisywać wartości
    logiczneLubNiezdefiniowane: boolean | undefined;
    // w przypadku `| undefined` musimy już jawnie podać wartość undefined
    jakasStala: 'cos' | 'tekst';
}
// moglibyśmy też zdefiniować typ obiektowy za pomocą `interface`,
// ale dla uproszczenia artykułu pominiemy różnice

// prawidłowe przypisanie
var obiekt1: Obiekt = {
    poleTekstowe: 'tekst',
    poleNumeryczne: 73.12,
    jakasStala: 'cos',
    logiczneLubNiezdefiniowane: undefined,
};

// możemy też zrobić, że oprócz wskazanych pól obiekt może trzymać dowolne inne
type Obiekt2 = {
    // pod dowolnym kluczem (typu string) mogą być tylko wartości typu number lub string
    [k: string]: number | string;
    // wszystkie jawnie zdefiniowane muszą mieć typ będący podzbiorem typu wartości dowolnego pola
    znanePole: string;
}

// prawidłowe przypisanie
var obiekt2: Obiekt2 = {
    znanePole: 'tekst',
    dowolneInne: 73,
};

// błędne przypisanie
obiekt2.znanePole = 21; // Type 'number' is not assignable to type 'string'.

Kod znajdziesz na TypeScript Playground.

Co więcej, z racji tego, że nazwy pól nie mogą się powtarzać, obiekty są zbiorami. Więc mamy tu sytuację, że sam typ obiektowy jest zbiorem, który ma tylko jedną wartość — definicję obiektu zgodnego z typem, ale też i sama ta definicja jest zbiorem. Jednak nie możemy tu mówić typowo o podzbiorach, że wyciągnięcie pojedynczego pola będzie zgodne z typem bazowym. Takie sytuacje są niedopuszczalne:

type Obiekt = {
    poleTekstowe: string;
    poleNumeryczne: number;
    nieobowiazkoweLogiczne?: boolean;
    logiczneLubNiezdefiniowane: boolean | undefined;
    jakasStala: 'cos' | 'tekst';
}

var fragment = {
    poleTekstowe: 'tekst'
};

var obiekt: Obiekt = fragment;
// Type '{ poleTekstowe: string; }' is missing the following properties from type 'Obiekt': poleNumeryczne, logiczneLubNiezdefiniowane, jakasStala

Ten kod również możesz sprawdzić na TypeScript Playground.

Suma zbiorów na typach obiektowych

Po wprowadzeniu typów obiektowych wróćmy do operacji na zbiorach. Jak się zachowa typ obiektowy, gdy zrobimy unię typów? Otóż tak, jak każdy inny typ: będziemy mogli użyć albo jednego typu, albo drugiego. Najlepiej zilustruje to poniższy przykład:

type Obiekt = {
    poleTekstowe: string;
    poleNumeryczne: number;
}

type InnyObiekt = {
    logiczneLubNiezdefiniowane: boolean | undefined;
    jakasStala: 'cos' | 'tekst';
}

// tworzymy unię obu typów
type SumaObiektow = Obiekt | InnyObiekt;

// prawidłowe przypisanie wartości z typu Obiekt
var obiekt1: SumaObiektow = {
    poleNumeryczne: 20,
    poleTekstowe: 'jakiś tekst'
};
// prawidłowe przypisanie wartości z typu InnyObiekt
var obiekt2: SumaObiektow = {
    logiczneLubNiezdefiniowane: true,
    jakasStala: 'tekst'
};

Jak zawsze możesz go sprawdzić na TypeScript Playground.

Jednak pojawia się tutaj dodatkowa rzecz — typy obiektowe również się zsumują, w końcu też są zbiorami. Oznacza to, że będziemy mogli użyć dowolnych dostępnych pól jednocześnie, przynajmniej wszystkich z jednego typu. Zobacz te przypadki na tych samych typach:

// prawidłowe przypisanie wartości z obu typów
var obiekt3: SumaObiektow = {
    poleNumeryczne: 20,
    poleTekstowe: 'jakiś tekst',
    logiczneLubNiezdefiniowane: true,
    jakasStala: 'tekst'
};
// prawidłowe przypisanie wartości z typu Obiekt i wybranego z InnyObiekt
var obiekt4: SumaObiektow = {
    poleNumeryczne: 20,
    poleTekstowe: 'jakiś tekst',
    logiczneLubNiezdefiniowane: true
};
// prawidłowe przypisanie wartości z typu InnyObiekt i wybranego z Obiekt
var obiekt5: SumaObiektow = {
    poleNumeryczne: 20,
    logiczneLubNiezdefiniowane: true,
    jakasStala: 'tekst'
};
// błędne jest natomiast przypisanie tylko wybranych wartości z obu typów
var obiekt6: SumaObiektow = {
    poleNumeryczne: 20,
    jakasStala: 'tekst'
}
// Type '{ poleNumeryczne: number; jakasStala: "tekst"; }' is not assignable to type 'SumaObiektow'.
// Property 'logiczneLubNiezdefiniowane' is missing in type '{ poleNumeryczne: number; jakasStala: "tekst"; }' but required in type 'InnyObiekt'.

Ten przypadek również możesz sprawdzić na TypeScript Playground.

Iloczyn zbiorów

Następną operacją na zbiorach, którą oferuje TypeScript, jest iloczyn zbiorów. Odpowiada za nią operator & lub (w przypadku interfejsów) słowo kluczowe extends.

Iloczyn typów prostych

Zacznijmy najpierw od iloczynów typów prostych. Tutaj sprawa jest dość oczywista. Żadne z typów prostych nie przecinają się (patrz diagram na początku artykułu), więc ich iloczyn zawsze będzie zbiorem pustym (never):

// definiujemy typ będący iloczynem typów prostych
type LiczbaLogiczna = number & boolean; // type LiczbaLogiczna = never

// błędne przypisania - nie istnieje część wspólna number i boolean
var nieDaRady: LiczbaLogiczna = 1; // Type 'number' is not assignable to type 'never'.
var tezNieDaRady: LiczbaLogiczna = true; // Type 'number' is not assignable to type 'never'.
var nawetTo: LiczbaLogiczna = undefined; // Type 'undefined' is not assignable to type 'never'.

Możesz to sprawdzić na TypeScript Playground. Warto też dopisać sobie inne przypadki — zobaczysz wtedy, że zawsze otrzymasz typ never.

Jest jednak wyjątek od powyższej reguły — uniwersum. Wszystkie typy są podtypami any lub unknown, więc możemy wykonać iloczyn, ale zachowanie staje się nieoczywiste. Zobacz poniżej, co się dzieje w tym przypadku:

// wersja z any
type Liczby = number & any; // type Liczby = any
// prawidłowe przypisania
var poprawne1: Liczby = 1;
var poprawne2: Liczby = 'tekst';

// wersja z unknown
type Liczby2 = number & unknown; // type Liczby = number
// prawidłowe przypisanie
var poprawne3: Liczby2 = 1;
// błędne przypisanie
var niepoprawne: Liczby2 = 'tekst'; // Type 'string' is not assignable to type 'number'.

Ten kod również znajdziesz na TypeScript Playground.

Dlaczego tak się dzieje? Nie znalazłem dokładnego wyjaśnienia, ale należy założyć, że any nie jest „prawdziwym” uniwersum wszystkich zbiorów (typów). any to bardziej wytrych, ucieczka z systemu typów TypeScripta umożliwiająca wstawienie czegokolwiek. Jest nadtypem i podtypem wszystkiego jednocześnie. Znalazłem w Internecie przyrównanie go do czarnej dziury, która wsysa wszystko — to chyba najlepsze określenie natury any. Stąd iloczyn czegokolwiek z any daje any.

Prawdziwym uniwersum, nadtypem wszystkich typów, jest unknown, dlatego iloczyn unknown i konkretnego typu daje ów konkretny typ. unknown w przeciwieństwie do any nie jest równocześnie podtypem wszystkiego. Podtypem wszystkiego jest zbiór pusty, czyli never, niebędący zarazem nadtypem wszystkiego.

Jeszcze dla formalności zobaczmy, czy faktycznie never zachowuje się jak zbiór pusty:

type Liczby3 = number & never; // type Liczby = never

var niepoprawne: Liczby3 = 1; // Type 'number' is not assignable to type 'never'.

Kod jest dostępny na TypeScript Playground.

Iloczyn typów literal

Wiedząc, czym są typy literal w TypeScript i jak działa iloczyn zbiorów, ta część powinna być formalnością. Dlatego ograniczmy się jedynie do przykładu w kodzie:

// iloczyn podtypu i nadtypu zawsze daje podtyp
type Liczba1 = 21 & number; // type Liczba1 = 21;
var poprawna1: Liczba1 = 21;
var niepoprawna1: Liczba1 = 37; // Type '37' is not assignable to type '21'.

// bardziej złożony przypadek, ale działający tak samo
type StaleTekstowe = 'tekst' | 'cos';
type Tekst1 = StaleTekstowe & string; // type Tekst1 = "tekst" | "cos"
var poprawna2: Tekst1 = 'tekst';
var niepoprawna2: Tekst1 = 'inny'; // Type '"inny"' is not assignable to type 'Tekst1'.

// analogiczna sytuacja jest przy sumie literal różnych rodzajów
type TekstLiczba = 'tekst' | 21;
type Liczba2 = TekstLiczba & number; // type Liczba2 = 21
type Tekst2 = TekstLiczba & string; // type Tekst2 = "tekst"
var poprawna3: Liczba2 = 21;
var poprawna4: Tekst2 = 'tekst';
var niepoprawna3: Liczba2 = 'tekst'; // Type '"tekst"' is not assignable to type '21'.
var niepoprawna4: Tekst2 = 21; // Type '21' is not assignable to type '"tekst"'.

// oczywiście możemy też wyliczyć część wspólną typów literal
type Wspolny = StaleTekstowe & TekstLiczba; // type Wspolny = "tekst"
var poprawna5: Wspolny = 'tekst';
var niepoprawna5: Wspolny = 'cos'; // Type '"cos"' is not assignable to type '"tekst"'.
var niepoprawna6: Wspolny = 21; // Type '21' is not assignable to type '"tekst"'.

// gdy typy literal są rozłączne, ich iloczyn jest zbiorem pustym
type StaleTekstoweInne = StaleTekstowe & 'inny' // type StaleTekstoweInne = never
type TekstLiczbaInna = TekstLiczba & 37; // type TekstLiczbaInna = never
type TekstLiczbaLogiczny = TekstLiczba & boolean; // type TekstLiczbaLogiczny = never
var niepoprawna7: StaleTekstoweInne = 'inny'; // Type 'string' is not assignable to type 'never'.
var niepoprawna8: TekstLiczbaInna = 37; // Type 'number' is not assignable to type 'never'.
var niepoprawna9: TekstLiczbaLogiczny = false; // Type 'boolean' is not assignable to type 'never'.

Kod jest dostępny na TypeScript Playground.

Iloczyn typów obiektowych

W kontekście iloczynu ciekawie się dzieje, gdy chodzi o typy obiektowe, szczególnie że jego główne zastosowanie w TypeScript jest właśnie przy nich.

Tutaj sprawa jest o tyle ciekawa, że iloczyn de facto staje się sumą. Typ, który otrzymujemy, nie jest częścią wspólną, tylko połączeniem typów po obu stronach operatora. Zachowanie to jest bardzo zbliżone do działania dziedziczenia w językach obiektowych (stąd iloczyn można też uzyskać słowem kluczowym extends).

Możesz się zastanawiać — tylko jaki to ma sens? Czy tego nie robi już operator sumy? Otóż nie. Odwołajmy się teraz do spójników, którym odpowiadają dane operacje. Suma to odpowiednik spójnika lub — czyli coś jest jednym obiektem albo oboma w całości. W praktyce może być jednym w całości i częściowo też drugim. Iloczyn jest odpowiednikiem spójnika i. Oznacza to, że nasz nowy typ musi być oboma typami jednocześnie, nie możemy sobie w żaden sposób wybierać.

Ale dość teorii, zobaczmy to w kodzie:

// iloczyn dwóch obiektów z różnymi polami
type Obiekt1 = {
    poleTekstowe: string;
    poleNumeryczne: number;
}
type Obiekt2 = {
    poleLogiczne: boolean;
}
type DwaRozne = Obiekt1 & Obiekt2;
// musimy wypełnić pola z obu obiektów
var poprawne1: DwaRozne = {
    poleLogiczne: true,
    poleTekstowe: 'tekst',
    poleNumeryczne: 21
};
// niewypełnienie któregokolwiek prowadzi do błędu
var niepoprawne1: DwaRozne = {
    poleTekstowe: 'tekst',
    poleNumeryczne: 21
}
// Type '{ poleTekstowe: string; poleNumeryczne: number; }' is not assignable to type 'DwaRozne'.
// Property 'poleLogiczne' is missing in type '{ poleTekstowe: string; poleNumeryczne: number; }' but required in type 'Obiekt2'.

// analogicznie działa `extends`
interface Interfejs1 {
    poleTekstowe: string;
    poleNumeryczne: number;
}
interface Interfejs2 extends Interfejs1 {
    poleLogiczne: boolean;
}
// możemy zrobić przypisanie wcześniej zdefiniowanej zmiennej, bo typy zawierają te same pola
var poprawne2: Interfejs2 = poprawne1;

Kod jak zawsze jest dostępny na TypeScript Playground.

A co dzieje się w przypadku, gdy mamy takie same pola w obu typach? Wykonywany jest również na nich iloczyn. Oznacza to, że możemy dojść do przypadku, kiedy otrzymamy never. Zobacz poniżej:

type Obiekt = {
    poleTekstowe: string;
    poleNumeryczne: number;
}
// 1 przypadek: powtórzone pole jest podtypem
type Przypadek1 = Obiekt & { poleTekstowe: 'tekst' };
// prawidłowe przypisanie
var poprawne1: Przypadek1 = {
    poleNumeryczne: 21,
    poleTekstowe: 'tekst',
};
// błędne
var niepoprawne1: Przypadek1 = {
    poleNumeryczne: 37,
    poleTekstowe: 'coś', // Type '"coś"' is not assignable to type '"tekst"'.
};

// 2 przypadek: powtórzone pole jest zupełnie innego typu
type Przypadek2 = Obiekt & { poleTekstowe: number };
// każde przypisanie będzie teraz niepoprawne
var niepoprawne2: Przypadek2 = {
    poleNumeryczne: 21,
    poleTekstowe: 'tekst', // Type 'string' is not assignable to type 'never'.
};
var niepoprawne3: Przypadek2 = {
    poleNumeryczne: 37,
    poleTekstowe: 1, // Type 'number' is not assignable to type 'never'.
}

// zobaczmy, jak to działa przy interfejsie
interface Interfejs1 {
    poleTekstowe: string;
    poleNumeryczne: number;
}
// błąd kompilacji pojawi się już przy definicji interfejsu
interface Interfejs2 extends Interfejs1 {
    poleTekstowe: number;
}
// Interface 'Interfejs2' incorrectly extends interface 'Interfejs1'.
//  Types of property 'poleTekstowe' are incompatible.
//    Type 'number' is not assignable to type 'string'.

Ten kod również znajdziesz na TypeScript Playground.

Podsumowanie

Jak pokazałem w tym artykule, teoria zbiorów znalazła zastosowanie w informatyce nie tylko przy obsłudze kolekcji, co pokazałem poprzednio. Mogłeś(-aś) zobaczyć, że pozwoliła także w formalny sposób zdefiniować część języka programowania — jego system typów. Oczywiście jest to tylko drobny wycinek TypeScriptu. Nie chciałem wchodzić bardziej w zawiłości tego języka, wolałem ograniczyć się do zupełnych podstaw, które najprościej było przenieść na zagadnienia znane z logiki.

Też nie pomyśl, że przeniesienie definicji języka programowania na język logiki to coś zarezerwowane tylko dla TypeScriptu. On posłużył mi tu bardziej jako przykład, bo jest to język, z którym mam najwięcej do czynienia na co dzień, więc też najłatwiej mi o nim mówić. Do tego jest to też o tyle ciekawy przykład, że TypeScript ma bardzo rozbudowany system typów.

W kwestii, czy zrozumienie języka w taki sposób przez matematyczne definicje jest przydatne, zdecyduj samodzielnie. Mnie osobiście podejście takie pomogło lepiej zrozumieć, co się dzieje i dlaczego przy definiowaniu typów. Czasem warto zrozumieć, dlaczego pewne rzeczy działają w dany sposób, a nie ograniczać się do ślepego przyjęcia, że tak jest i tyle.

Literatura

Zdjęcie na okładce wygenerowane przez Stable Diffusion.