Dynamicznie typy danych to nie zaleta, ale problem

18 października
Maciej Sikora
Dynamicznie typy danych to nie zaleta, ale problem
Programowaniem zajmuję się od dobrych 10 lat. Zacząłem swoją przygodę od PHP i JavaScript, a powodów było kilka.

Otóż w tym okresie takie technologie były bardzo popularne, większość ofert pracy, to właśnie poszukiwanie programisty PHP/JavaScript. Kolejnym powodem takiego wyboru, była prostota, z jaką można było rozpocząć pracę z tymi językami. Uzasadnieniem tej łatwości jest ich dynamiczne typowanie, czyli fakt, że programista nie musi dokładać do swojego kodu żadnych deklaracji typów, czy założeń teorii typów. Inaczej mówiąc można szybko “coś” stworzyć. Przyznaję, że przez większość mojej kariery zawodowej byłem przekonany, że dynamicznie typowane języki są świetne, ale… nie są.

Przez lata programowałem w edytorach kodu, które nie oferowały mi zbyt wiele. Wszystko odbywało się przez leksykalne wyszukiwanie plików. Opcje takie jak: gdzie funkcja jest użyta, przejdź do deklaracji, zmień nazwę we wszystkim miejscach, były nieosiągalne. Dopiero moja przygoda z programowaniem aplikacji mobilnych w języku Java zaczęła otwierać mi oczy na pewne aspekty. Pierwszą różnicę jaką zauważyłem w pracy z językiem Java, była sprawność współpracy między językiem a środowiskiem developerskim. Android Studio pracował z językiem jak jeden organizm, podpowiadanie kodu, czy automatyczne importy to standard do którego szybko przywykłem. Statyczne typowanie pozwoliło mi wyłapać bardzo dużo błędów jeszcze przed kompilacją, deklaracje metod i funkcji pozwoliły na łatwość w ich użyciu, bez szukania dokumentacji. A refactoring? W takich warunkach to prawdziwa bajka - opcja zmień nazwę funkcji, zmiennej czy klasy wykonuje modyfikację we wszystkich jej wystąpieniach.

Powrót do JavaScript z języka Java był bolesny. Nie dlatego, że uważam JavaScript za język zły, ale dlatego, że praca z nim wydaje się oparta na gruncie, którego nawet nie widać. W JS, od razu wróciłem do problemów takich jak znane:

  Uncaught TypeError: Cannot read property 'getApples' of undefined
    at :1:8

Dla programisty Front-End to codzienność. Pierwszy odruch to: dodać warunek sprawdzający czy mamy obiekt, ale to droga donikąd. Dodając warunki w każdej sytuacji dochodzimy do defensywnego kodu, który sprawdza nawet niemożliwe, a błąd i tak wyświetla się dopiero kiedy kod ruszy w naszej przeglądarce lub serwerze (node.js). Warto wspomnieć o tym, że w sytuacji kiedy dany blok kodu jest otoczony warunkami i znajduje się w nim błąd, to mamy dużą szansę na jego ominięcie!

Programowanie w JavaScript jest jak chodzenie po polu minowym. Po prostu wszystko może wysadzić twoją aplikację. Możesz użyć funkcji oczekującej ciągu znaków, wprowadzając do niej numer, tak możesz, a JavaScript powie – nic. W środowisku uruchomieniowym JavaScript potrafi tylko rzucać wyjątkami, a w trakcie pisania kodu jest niemy. JavaScript pokaże błąd dopiero, kiedy program będzie działał, albo dopiero, kiedy znajdzie się na serwerze produkcyjnym, ponieważ akurat takiego przypadku nie sprawdziłeś.

Od roku nie piszę w JavaScript, nie żałuję, nie zamierzam wracać. Wybrałem lepsze - TypeScript TypeScript to tak naprawdę ten sam język, ale z jedną dużą różnicą, wprowadza przewidywalność, pewność i kontrolę w postaci systemu typów. TypeScript wprowadza jeszcze jedną ważną rzecz - etap transpilacji, który pozwala na nadzorowanie zgodności kodu w trakcie jego pisania. W ostateczności przeglądarka będzie obsługiwać sam JavaScript, ale dzięki transpilacji, wszelkie niezgodności struktur danych będą zauważone w najwcześniej możliwym momencie, przed uruchomieniem, wtedy kiedy jesteś najbliżej problemu i wiesz jak go naprawić.

Transpilacja - rodzaj kompilacji polegający na zamianie jednego języka w drugi o podobnym poziomie abstrakcji.

Przykład porównawczy będzie dość trywialny, ale jego zadaniem jest pokazać różnicę. Oto funkcja, której zadaniem jest znalezienie użytkownika po id:

  const findUserById = (users, id) => {
  return users.find(user => user.id === id);
}
const users = [
  {name: 'Tom', userId: '1'},
  {name: 'Patrick', userId: '2'},
  {name: 'Jack', userId: '3'}
];

Tablica users jest przygotowana jako dane wejściowe do funkcji findUserById. Zapewne od razu zauważyłeś błąd, jaki może znaleźć się w tym kodzie. Przejdźmy jednak dalej:

Co nam mówi środowisko developerskie (Visual Studio Code)? Otóż, definiuje dwie wiadome informacje:

  • taka funkcja istnieje
  • taka funkcja ma dwa argumenty

Oraz dwie niewiadome:

  • jakie są typy argumentów
  • co funkcja zwraca

Użyję funkcji: findUserById(users). Wynik - zaskakujący, działa, brak błędów. Ale jak działa? Oczywiście źle. Jest to najgorszy z możliwych błędów, jest to błąd w stylu “Silent Failure”. To jak nieszczelność w Titanicu, a my dalej w sali balowej, nieświadomi niebezpieczeństwa. Niestety na tym etapie programista nie ma pojęcia, że kod jest błędny. Dopiero włączenie aplikacji i realne sprawdzenie jej działania powie, że coś jest nie tak. Klikamy w użytkownika, a on nigdy się nie pojawia. Ale jeśli UI nam mówi, że coś nie działa, proces szukania błędu to typowe debugowanie, klikanie i próba odzwierciedlenia sytuacji. Proces ten to czysta strata czasu, a kontynuować go będziesz dopóki nie będzie jasne, że błąd znajduje się w użyciu findUserById(users).

Dlaczego błąd wystąpił? Jedynym sposobem w JavaScript jest sprawdzenie implementacji funkcji, implementacja pokazuje, że funkcja oczekuje kolekcji w której element posiada własność id a nie userId. Sytuacja w której taki “Silent Failure” jest znaleziony na poziomie UI przez programistę nie jest najgorszą sytuacją. Może być gorzej, otóż jeśli przed wywołaniem funkcji mamy warunki, dla przykładu:

  if (currentUser.type === 'admin' && isNight()) {
  findUserById(users);
}

Dodatkowa warunkowość powoduje, że nie jest łatwo sprawdzić działania funkcji. Problem można przeoczyć. Nasz programista nie pracuje w nocy, więc jego aplikacja nie wejdzie w warunek, programista nie ma pojęcia, o fakcie iż ten kod po prostu nie działa.

Powyższy kod na tym etapie posiada dużą dozę niepewności, nie ma błędów, ale nie działa prawidłowo. Utrzymanie takiego kodu również będzie problemem. Każda zmiana funkcji findUserById powoduje regresję, ponownie cichą, nie mamy pojęcia co i gdzie może przestać działać.

Można to naprawić! Otóż można postawić tzw. Guarda na argumenty wejściowe.

  const findUserById = (users, id) => {
  if (typeof users !== "array" || typeof id !== 'string') {
    throw new Error('Please provide two arguments -> users = array of user and id =  id of wanted user');
  }
  return users.find(user => user.id === id);
}

Teraz jest lepiej, użycie z jednym argumentem wyrzuca wyjątek:

  Uncaught Error: Please provide two arguments -> users = array of user and id =  id of wanted user

Niestety takie zabezpieczenie ma wiele wad, należy dopisać dużo bezużytecznego kodu, w powyższym przypadku kod defensywnie broniący argumentów funkcji zajmuje więcej niż realne ciało funkcji. Kolejną wadą jest brak sprawdzenia struktury tablicy, funkcja nie zadziała z każdą tablicą, ale z tablicą elementów o konkretnej strukturze. Sprawdzania tej struktury bezpośrednio w kodzie spowodowałoby, że większość kodu aplikacji nie robi nic poza walidacją argumentów. Defensywna walidacja argumentów funkcji to droga donikąd.

TypeScript na pomoc

Spróbuję tą samą funkcjonalność napisać w TypeScript. Już na samym początku wprowadzania typów jest widoczny błąd:

Nie ma o czym myśleć. Wszystko jest jasne, własności id nie ma w typie User. Taki kod nie będzie kompilowany. Już na etapie pisania błąd został wyłapany .

Poniżej pełny kod TypeScript tego samego przypadku:

  interface User {
  userId: string;
  name: string;
}
const findUserById = (users: User[], id: string) => {
 return users.find(user => user.userId === id);
}
const users: User[] = [
 {name: 'Tom', userId: '1'},
 {name: 'Patrick', userId: '2'},
 {name: 'Jack', userId: '3'}
];

Próba użycia funkcji findUserById:

Visual Studio Code świetnie współpracuje z TypeScript. Powyżej widać jak idealnie podpowiada w jaki sposób funkcja ma być użyta i z jakimi argumentami. Próba wprowadzenia błędnych argumentów kończy się błędem. Z użyciem TS wszystko staje się wiadome na poziomie pisania kodu:

  • taka funkcja istnieje
  • taka funkcja ma dwa argumenty
  • jakie są typy argumentów
  • co funkcja zwraca

Na koniec omówię ostatni aspekt - Co funkcja zwraca. TypeScript dzięki inferencji typów jest w stanie określić fakt, że funkcja findUserById zwraca typ - User | undefined.

Inferencja typów - automatyczne detekcja typu na podstawie kodu

Co za tym idzie poniższy kod będzie przez TypeScript uznany za błędny:

Nie można odwoływać się do własności obiektu, który może być undefined. TypeScript odnotowuje możliwy błąd w trakcie kodowania. Od razu można błąd naprawić:

  const foundUser = findUserById(users, '3');
if (foundUser) {
  console.log(foundUser.name)
} else {
  console.log('No User found');
}

Zanim kod zostanie uruchomiony programista widzi ogromną ilość błędów jakie może naprawić. TypeScript nie pozwala na pisanie kodu niezgodnego z kontraktem, kod musi spełniać założenia, jeśli funkcja zwraca typ X, dalsza część kodu musi używać typu X.

Kontrakt - ustalenia dotyczące różnych aspektów aplikacji. Kontraktem możemy nazwać ustalenia dotyczące struktur danych, typów danych, obsługiwanych kodów błędów.

Podsumowując, TypeScript to ogromny krok do przodu w kwestii jakości i przewidywalności oprogramowania. Współpraca Visual Studio Code + TypeScript to najlepsze co w ostatnich latach mogło przydarzyć się developerom front-end.

Pole minowe po jakim byłem przyzwyczajony chodzić zostało zabezpieczone, miny rozbrojone, grunt znów jest widoczny, Titanic załatany. Można spać spokojnie.