Oskar Dudycz

Pragmatycznie o programowaniu

Strukturalne typowanie w TypeScript, czyli these are not the droids you are looking for

2021-03-01 oskar dudyczSztuka Programowania

cover

Cześć!

Na początku chciałem Ci się pochwalić. W zeszły piątek udało mi się zadebiutować jako “Keynote speaker”. Wystąpiłem z tematem “Dostarczajmy jakość, a nie jakoś” na V Forum Achitektury MBank. Jest to wewnętrzna konferencja organizowana zwykle z tematem przewodnim. W tym roku była nią jakość. Co ciekawe w zeszłym roku było to “Fast Delivery”. Widać pewną zależność. Każdy myślę słyszał o wpadce z powiadomieniami itd. Śmieszkować można, ale tak naprawdę, każdy popełnia błędy. Nie każdy jednak umie uderzyć się w pierść, wyciągnąć wnioski i podjąć rękawicę. To trzeba docenić. Może będę miał nagranie to się podzielę, jeśli nie to może zrobię webinar dla zapisanych na newsletter. Wystąpienie było o niby prostych, ale nieoczywistych rzeczach mających wpływ na jakość oprogramowania.

Dzisiaj chciałbym też poruszyć pewną nieoczywistą rzecz czyli strukturalne typowanie w TypeScript. Co to jest?

Gdy mówimy o typowaniu zwykle dzielimy je na statyczne i dynamiczne. Statyczne to w domyśle robione na etapie kompilacji, czyli języki typu Java, C#, C++. Dynamiczne to takie gdzie typy są sprawdzane dopiero w momencie działania programu, czyli np. Python i JavaScript.

Obydwa typowania mają swoje wady i zalety.

Dynamiczne typowanie pozwala na pisanie bardziej zwięzłego kodu. Możemy dokonywać dowolne transformacje, byleby na końcu ich efekt był oczekiwany. Pozwala to skrócić ceremonię, ale wymaga większej wiedzy (lub szczęścia) jeśli chce się to robić dobrze. Można się ratować oczwyiście analizą statyczną kodu (wszelakie lintery, analizatory, itd.). Podstawa to dobry zestaw testów.

Typowanie statyczne pozwala już na etapie kompilacji rozpoznać podstawowe błędy, np. błędne przypisanie typu, brak wymagalnych pól, czy tez po choćby głupią literówkę w nazwie pola. Łatwiej robić refactoring, bo od razu widzimy czy jebło czy nie. W zespole, który ma zróżnicowany poziom łatwiej też “utrzymać porządek”. W teorii typy i kompilacja nas uchronią przed głupimi błędami. Jednakże też często musimy się nagimnastykować, aby nasz system typów zadowolić. Często trzeba wygenerować więcej kodu. Coś za coś.

Tutaj docieramy do tematu tego maila. Co to jest strukturalne typowanie?

Typowanie statyczne możemy podzielić na typowanie nominalne/symboliczne (nominal typing) oraz strukturalne (structural typing).

W typowaniu nominalnym przy ocenie czy dany obiekt jest danego typu weryfikujemy:

  • nazwę typu (stąd też typowanie nominalne/symboliczne.)
  • obecność i nazwy pól,
  • typy pól.

Typowanie strukturalne nie interesuje nazwa typu. To co go interesuje to czy struktura obiektu zgadza się z definicją typu (nazwa, obecność i typy pól). Ja nazywam to przystawianiem szablonu. Mamy wycięty jakiś wzór i jeśli sie obiekt przez niego przeciśnie to jest danym typem. Można to też porównać do definicji człowiek jest ssakiem, ma nogi, ręce i głowę. Typowanie więc sprawdzi wszystkie te cechy. Z tym, że jeżeli definicja jest zbyt ogólna to małpa też może zostać uznana za człowieka. Też jest w końcu ssakiem, ma nogi, ręce i głowę.

Zobacz poniżej:

interface Human {
    legs: number,
    hands: number
}

interface Employee {
    legs: number,
    hands: number,
    name: string
}

const johnDoe = {
     legs: 2,
     hands: 2,
     name: "John Doe"
}

const ape = {
     legs: 2,
     hands: 2
}

const snake = {
    tounge: true,
}

// OK
let human: Human = johnDoe;
let employee: Employee = johnDoe;

// OK
human = ape; 

// Fail - missing name
employee = ape;

// Fail - missing legs, hands
human = snake;
// Fail - missing egs, hands, name
employee = snake;

Jak widzisz poza zmiennymi same obiekty tworzone nie mają żadnych typowań, a i tak się zgadza. Na początek wygląda to dziwnie, ale potem daje bardzo dużo możliwości. Jeżeli np. mamy funkcję do wyświetlenia imienia, to nie musimy tworzyć dodatkowego interfejsu i każdej klasie kazać go zaimplementować, wystarczy, że zrobimy tak:

function printName(name: { firstName:string, lastName: string }) {
    console.log(`${name.firstName} ${name.lastName}`);
}

Pozwala to na dużo mniej ceremonii, szczególnie jeśli piszemy w stylu bardziej funkcyjnym.

Dlaczego Tobie o tym piszę? Bo jak wszystko ma to swoje wady i zalety. Dużo osób przechodzących ze świata “typowania nominalnego” (czytaj C#, Java) nie próbuje zrozumieć, że TypeScript pozornie jest językiem tego samego (nomen omen) typu. Na siłę starają się przenosić swoje upodobania do generyczności, do wpychania interfejsów wszędzie i klas. Nie róbmy tak, ograniczamy w ten sposó pole manewru i sprowadzamy nasze środowisko do najmniejszego wspólnego mianownika.

Pal jednak licho gdy ktoś po prostu ciągnie balast za sobą. Jak ktoś lubi i koniecznie chce to niech się tak bawi. Problem jest gdy zapomina, że TypeScript nie jest językiem kompilowalnym - on jest językiem transpilowalnym. Różnica między jednym, a drugim jest taka, że TypeScript nie wymusza typów, on je tylko weryfikuje, a następnie robi translację do JavaScript. Gdy to jest zrobione i uruchamiasz swój kod to jest to już dynamicznie typowalny JavaScript. Papier wszystko przyjmie, JavaScript też.

Najgorsze co możemy zrobić to taką obsługę requesta:

function addEmployee(user: Employee) {
    if (!user?.name && user?.legs !== 2 && !user?.hands !== 2) {
        throw "Not an Employee";
    }
    saveToDatabase(user);
}

Dlaczego to takie złe? Walidacja, jest, wszystko się zgadza. Otóż nie. Tak naprawdę pod spodem (szczególnie po deserializacji) nasz obiekt może mieć jeszcze dodatkowe pola, struktura może się nie zgadzać - np. ktoś może wysłać:

{
    "name": {
        "firstName": "John",
        "lastName": "Doe"
    },
    "legs": 2,
    "hands": 2,
    "iWillSpamYourDB": "someExtremelyLargeText(...)"
}

I jeśli to po prostu zdeserializujemy i przyjmiemy, że dobrze wiemy jaki to jest typ pod spodem to się naprawdę zdziwimy. Dlatego pierwsze co proponuję zrobić to po zwalidowaniu zrobić przepisanie pól i utworzyć nowy obiekt. Obiektom w naszym kodzie możemy ufać (no powiedzmy), zewnętrznym nigdy.

function addEmployee(userRequest: any) {
    if (!userRequest?.name && userRequest?.legs !== 2 && !userRequest?.hands !== 2) {
        throw "Not an Employee";
    }
    const user: User = {
        name: userRequest.name,
        legs: userRequest.legs,
        hands: userRequest.hands
    }
    saveToDatabase(user);
}

Może się to wydawać nadmiarowe, ale daje nam podstawowe zaufanie do naszego kodu i typów plus broni nas przed niebezpiecznymi zapytaniami.

Osobiście uważam, że typowanie strukturalne jest super, upraszcza życie i daje duże możliwości. To o co apeluję, to jak zaczynamy pracę w nowym środowisku to zrozummy je. Nie idźmy na łatwiznę i nie próbujmy ślepo przenosić naszych przyzwyczajeń. Bo może się okazać, że to nie są te droidy, których szukamy.

Pozdrawiam Oskar

P.S.

W tym tygodniu wysyp wpisów ode mnie:

A oprócz tego jak co tydzień nowe Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#1st-march-2021

  • © Oskar Dudycz 2019-2020