
Dlaczego nie warto ufać API?
Jeżeli jesteś doświadczonym developerem, to zapewne domyślasz się, co mam na myśli. Jeśli nie, to i tak zapraszam na krótki artykuł o tym, dlaczego warto mapować obiekty z API. Przedstawię również praktyczne przykłady, które często z powodzeniem wdrażam w swoich projektach.
Typowa historia z życia developera
Krótka historyjka, która z pewnością gdzieś na świecie codziennie ma miejsce :)
Spotkanie o 12:30, ustalony meeting frontend vs backend, żeby dogadać szczegóły kontraktu dotyczącego tworzenia użytkowników. Dogrywacie wspólnie DTO-sy. Wszystkie typy ustalone, taski rozpisane, czas na implementację.
Frontendowiec zadowolony, z pełną wiedzą co ma zrobić, przystępuje do realizacji. Robi sobie mocki na podstawie ustalonych typów, tworzy komponenty, hooki, serwisy i wreszcie integruje się z backendem - wisienka na torcie. Już zaciera ręce, żeby wrzucić taska na środowisko testowe i rozkoszować się kawusią, ale niestety nie jest tak pięknie, jak myślał.
Pierwsze problemy
Pierwszy problem - komponenty nie wyglądają tak, jak z testowymi danymi z mocków.
Zadaje sobie pytanie: jak to możliwe i co to się stało?
Niechętnie spoglądając na konsolę (myśląc już o tej przepysznej kawusi) zauważa errory. TypeScript płacze, komponenty płaczą, hooki płaczą. Krew w konsoli. No dobra, zakasuje rękawy, robi w końcu tę kawę, bo wie, że to będzie grubsza robota, z 10 minut debugowania :D
Po wstępnych oględzinach frontendowiec widzi, że backend się nie spisał i zapomniał finalnie sprawdzić czy typ się rzeczywiście pokrywa z ustalonym DTO. Akurat robił to stażysta, bo tylko on miał moce przerobowe w danym momencie i zwyczajnie nie zdawał sobie sprawy, że takie szczegóły mają znaczenie. Enumy miały być upper-case'em, a lastname zawsze stringiem, choćby pustym, ale jednak stringiem, a przyszedł undefined
, itd...
// Oczekiwane UserDto
interface UserDto {
id: number;
firstName: string;
lastName: string;
role: 'ADMIN' | 'USER' | 'GUEST';
avatarUrl: string | null;
}
// Rzeczywiste dane z API
{
id: 1,
firstName: "John",
lastName: undefined,
role: "admin",
avatarUrl: undefined
}
Naprawiamy, ale...
Frontendowiec wziął się w garść, pół godzinki, DTO ponownie uzgodnione, typy naprawione, wszystko się ładnie wyświetla. Poszło do QA, zostało zaakceptowane. Działa.
Dwa dni później znowu się wysypało - drugi backendowiec, co refactorował obrazki, zapomniał zmienić struktury danych dla obrazków. Frontendowiec dostał już ticketa z bugiem, bo przecież to front nie wyświetla obrazków :)
I znowu szukanie, debugowanie, czerwono w konsoli... Pół godziny debugowania i jest! Okazuje się, że obrazkowi brakowało jednego property, drobny błąd. Poprawione. Znowu działa.
Problem z perspektywy czasu
Temat był świeży, debugowanie poszło sprawnie i błąd został szybko wykryty. Co jeśli taki przypadek zdarzyłby się za pół roku, a ten nieszczęsny obrazek byłby używany w 50 innych typach danych?
Rozwiązanie tego problemu i wielu innych problemów sugeruje już początek tego wpisu. Są to mappery. Czasami też nazywane transformersami, adapterami, jak zwał tak zwał - ja nazywam te funkcje mapperami.
// Przykład prostego mappera
const assureString = (value: unknown): string => {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
return String(value);
};
Dlaczego to problem?
Jeśli byśmy przyjęli, że typ pochodzący z API to po prostu UserDto, to trochę sami prosimy się o katastrofę. Dlaczego?
- ta struktura wcale nie musi być tego typu, dopóki się nie upewnimy i nie zapewnimy, że jest tego typu
- ciężko nam później pracować z obiektem, który jest "niepewny" i jesteśmy zmuszeni ładować pytajniki "?", żeby TypeScript jednak sprawdził, czy dane property rzeczywiście istnieje
- nie wiemy, w którym miejscu w aplikacji się coś wysypało, a czasami znalezienie takiego błędu w dużym projekcie kosztuje pół dniówki średnio doświadczonego developera
- nie oddzielamy warstwy tego, co pochodzi "z zewnątrz" od tego, co mamy "wewnątrz" aplikacji
- piszemy bardziej skomplikowane interfejsy dla komponentów, niż moglibyśmy
- piszemy niespójne interfejsy dla komponentów, bo raz enum przyjdzie camelCasem, a raz UPPER_CASEM
- nie stosujemy wewnętrznych typów aplikacji, bo przecież mamy DTO-sy, to po co robić drugi, bardzo podobny typ
Co nam dają mappery?
- przede wszystkim z czystym sumieniem możemy poinformować TypeScript, że zadeklarowany typ to prawda
- pozwala nam utrzymać spójne struktury danych wewnątrz aplikacji
- jak backend coś zmieni, świadomie lub nie, będziemy o tym od razu dobrze poinformowani
- od razu mamy walidator danych, czy typ, na który się umówiliśmy w kontrakcie, jest prawidłowo zaimplementowany
- aplikacja nie wysypie się przy braku głupiego property
- mamy spójne dane, nawet gdy są różne źródła danych
Kiedy stosować mappery?
Stosujemy mappery, gdy dane pochodzą z zewnętrznego źródła - w tym przypadku z API. Innym zewnętrznym źródłem może być chociażby zawartość cookie czy LocalStorage. Przecież każdy może wejść w dev toolsy i zamiast obiektu wstawić string, czy zamiast booleana - number, a to nam może również wysypać aplikację i spowodować, że będzie ona działać niestabilnie.
Kiedy odpuścić mappery?
To kiedy sobie odpuścić takie mappery?
- jak piszesz fullstackowo aplikację sam dla siebie
- jak robisz proste MVP, które zaraz i tak "zaorasz"
- gdy ORM (np. TypeOrm) robi to za nas :)
Podsumowanie
Ten artykuł stanowi pierwszą część z serii o mapowaniu danych z API. W kolejnej części:
- Poznasz praktyczne implementacje mapperów dla różnych przypadków użycia
- Zobaczysz jak radzić sobie z zagnieżdżonymi obiektami
- Dowiesz się jak obsługiwać tablice obiektów
- Poznasz techniki cachowania zmapowanych danych
- Nauczysz się pisać testy dla mapperów
- Zobaczysz przykłady wykorzystania generycznych typów w mapperach
- Poznasz popularne biblioteki do mapowania danych
Zachęcam do eksperymentowania z własnymi implementacjami mapperów i dostosowywania przedstawionych rozwiązań do swoich potrzeb.
Objaśnienie skrótów
- API - Application Programming Interface - Interfejs Programowania Aplikacji
- DTO - Data Transfer Object - Obiekt Transferu Danych, struktura danych służąca do przesyłania informacji między systemami
- QA - Quality Assurance - Zapewnienie Jakości, proces weryfikacji jakości oprogramowania
- MVP - Minimum Viable Product - Minimalny Możliwy Produkt, wersja produktu z minimalnym zestawem funkcji
- ORM - Object-Relational Mapping - Mapowanie Obiektowo-Relacyjne, technika konwersji danych między systemami typów w bazach danych
- TypeORM - Biblioteka ORM dla TypeScript i JavaScript, ułatwiająca pracę z bazami danych
- ADMIN - Administrator - użytkownik z najwyższymi uprawnieniami w systemie
- UI - User Interface - Interfejs Użytkownika, warstwa wizualna aplikacji
- UPPER_CASE - Konwencja nazewnictwa używająca wielkich liter i podkreśleń (np. USER_NAME)
- camelCase - Konwencja nazewnictwa zaczynająca się małą literą, gdzie kolejne słowa zaczynają się wielką literą (np. userName)
Linkografia do dalszej exploracji :)
- Mapowanie danych z API - część 2: zaawansowane techniki
- TypeScript: Przewodnik po zawężaniu typów (Type Narrowing)
- Type Guards w TypeScript - jak pisać bezpieczniejszy kod
- Biblioteka Zod - walidacja i typowanie danych w TypeScript
- Jak testować mappery? Praktyczne podejście do testów jednostkowych
- Wzorzec projektowy Adapter - implementacja w TypeScript
- Mapowane typy w TypeScript - jak korzystać jak profesjonalista
- Strategie obsługi błędów przy komunikacji z API
- Zod - Oficjalne repozytorium GitHub

Konrad Bysiek
Frontend Developer / Tech Lead