Blog Cover Image
22 min czytania

Mappery

Wstęp

W poprzednim artykule wyjaśniłem, dlaczego warto używać mapperów i nie warto ufać poprawności danych pochodzących z zewnątrz. W tym artykule chciałbym pokazać, jak można używać mapperów w praktyce.

Jeżeli nie pamiętasz, czym są type guards w TS i potrzebujesz odświeżyć sobie tę wiedzę, to link znajdziesz na końcu artykułu. Jeśli coś Ci świta, to śmiało możesz czytać dalej, bo przykładów będzie sporo :)

Nie przedłużając, zaczynajmy!

Prymitywy, czyli typy proste

String

Jeżeli oczekiwany typ z API to string, to chcemy się upewnić, że to faktycznie jest string.

const isString = (value: unknown): value is string => typeof value === 'string';

const assureString = (value: unknown): string => {
  if (!isString(value)) {
    console.warn('Expected string, got:', value);
    return '';
  }
  return value;
};

Warto zauważyć, że celowo argument value jest typu unknown. Nie określamy typu, ponieważ faktycznie nie wiemy, co to jest za typ na tym etapie. Dopiero użycie funkcji assureString pozwala stwierdzić, że wartość jest stringiem.

Użycie funkcji z niepoprawną wartością 1 jako number będzie skutkowało warningiem w konsoli, co pozwala na szybkie zauważenie problemu. Błędne użycie assureString

Oczekiwaliśmy z API stringa, ale dostaliśmy liczbę. W takim przypadku zwracamy pusty string i logujemy ostrzeżenie. Warning w konsoli pozwala nam na szybkie zauważenie problemu. W przypadku błędnego typu zwracamy wartość domyślną '' oczekiwanego typu - string.

Number

Tutaj sprawa będzie bardzo podobna do stringa, więc pozwolę sobie pominąć wyjaśnienie.

const isNumber = (value: unknown): value is number => typeof value === 'number';

const assureNumber = (value: unknown): number => {
  if (!isNumber(value)) {
    console.warn('Expected number, got:', value);
    return 0;
  }
  return value;
};

Uczulam jedynie na to, że wartość domyślna dla liczby to 0, co jest zgodne z typem, ale niekoniecznie będzie oczekiwaną wartością w jakimś scenariuszu. No ale przecież w idealnym świecie ten warning się nigdy nie pojawi, prawda? A nawet jeśli, to od razu o tej "anomalii" jesteśmy poinformowani warningiem i trzeba jak najszybciej:

  • uzgodnić poprawny typ z backendem,
  • poprawić typ w API lub/i poprawić typ na froncie,
  • upewnić się, że wszystko działa poprawnie i warning już nie występuje.

Enumy

Weźmy na przykład enuma, który reprezentuje role użytkowników w systemie.

export const OUserRole = {
  Guest: 'guest',
  Consultant: 'consultant',
  Admin: 'admin',
} as const

export type UserRole = (typeof OUserRole)[keyof typeof OUserRole];

Upewniamy się, że wartość z API jest jedną z wartości zdefiniowanych w enumie:

const isUserRole = (value: unknown): value is UserRole => Object.values(OUserRole).includes(value as UserRole);

const assureUserRole = (value: unknown): UserRole => {
  if (!isUserRole(value)) {
    console.warn('Expected UserRole, got:', value);
    return OUserRole.Guest;
  }

  return value as UserRole; // value jest oczekiwanym enumem, więc możemy z czystym sumieniem rzutować
};

Obiekty

Jeśli już mamy odpowiednio przygotowane mappery dla wartości prymitywnych, to teraz czas na bardziej złożone obiekty.

type User = {
    name: string;
    age: number;
    role: UserRole;
}

const isUser = (value: unknown): value is User => {
  const hasValidFields = (
    typeof value === 'object' &&
    isString((value as User).name) &&
    isNumber((value as User).age) &&
    isUserRole((value as User).role)
  );

  return hasValidFields;
};

const assureUser = (value: unknown): User => {
  if (isUser(value)) {
    return value as User;  
  }
    
  console.warn('Expected User, got wrong object, but we will try to use as much as possible from it:', value);

  return {
    name: assureString((value as User).name),
    age: assureNumber((value as User).age),
    role: assureUserRole((value as User).role),
  };
};

W powyższym przykładzie sprawdzamy, czy obiekt ma odpowiednie pola i czy są one odpowiedniego typu. Jeśli tak, to zwracamy obiekt. W przeciwnym wypadku i tak próbujemy zmapować jak najwięcej danych, które są poprawne.

Kolekcje (tablice)

Lista użytkowników - przejdźmy do przykładu.

const isUserList = (value: unknown): value is User[] => {
  return Array.isArray(value) && value.every(isUser);
};

const assureUserList = (value: unknown): User[] => {
  if (isUserList(value)) {
    return value as User[];
  }

  console.warn('Expected User[], got:', value);

  return [];
};

Tutaj nie ma co próbować mapowania tablicy, która nie jest tablicą. W takim przypadku zwracamy pustą tablicę.

Inne typy

Sprawa wygląda podobnie dla innych typów, np. daty, obiektów zagnieżdżonych, itp. Jeśli typ jest np. opcjonalny to wystarczy zrobić kolejny mapper, który to uwzględni. Zachęcam do eksperymentowania we własnym zakresie.

Poprawa czytelności logów

Pewnie zauważyłeś, że w przypadku błędnego typu logujemy ostrzeżenie. Warto zastanowić się, czy nie warto byłoby zrobić bardziej rozbudowanego logowania, które pozwoliłoby na szybsze zlokalizowanie problemu.

Aktualnie dostajemy informację, że oczekiwany był inny typ, ale nie wiemy, gdzie dokładnie wystąpił problem. Możemy to poprawić, dodając informację o tym, gdzie wystąpił problem poprzez przekazanie ścieżki do błędnego pola.

const isString = (value: unknown): value is string => typeof value === 'string';

const assureString = (value: unknown, key: string): string => {
  if (!isString(value)) {
    console.warn(`Expected string for key "${key}", got:`, value);
    return '';
  }
  return value;
};

const assureUser = (value: unknown, key: string): User => {
  if (isUser(value)) {
    return value as User;  
  }
    
  console.warn(`Expected User for key "${key}", got wrong object, but we will try to use as much as possible from it:`, value);

  return {
    name: assureString((value as User).name, `${key}.name`),
    age: assureNumber((value as User).age, `${key}.age`),
    role: assureUserRole((value as User).role, `${key}.role`),
  };
};

Podsumowanie

Mam nadzieję, że wiesz już, jak można używać mapperów w praktyce. Wiele z powyższych zadań mogą nam ułatwić gotowe biblioteki takie jak Zod czy Yup, ale warto wiedzieć, jak powinno to działać "pod spodem", szczególnie, że te biblioteki czasami mają pewne ograniczenia.

Objaśnienie skrótów

  • API - Application Programming Interface - Interfejs Programowania Aplikacji
  • TS - TypeScript - język programowania będący nadzbiorem JavaScript, dodający statyczne typowanie
  • UserRole - Typ wyliczeniowy definiujący role użytkownika w systemie (np. Guest, Consultant, Admin)
  • type guard - Mechanizm w TypeScript służący do sprawdzania typu w czasie wykonania programu
  • Zod - Biblioteka do walidacji i typowania danych w TypeScript
  • Yup - Biblioteka do walidacji schematów w JavaScript i TypeScript

Linkografia do dalszej exploracji :)

Konrad Bysiek - avatar

Konrad Bysiek

Frontend Developer / Tech Lead