HomeO mnie

TypeScript na straży komponentów reactowych

By Robert Duraj
Published in TypeScript
July 13, 2022
2 min read
TypeScript na straży komponentów reactowych

Kilka dni temu omówiliśmy przykład unii dyskryminowanych. Podkreśliliśmy, jak dużo bezpieczeństwa one zapewniają pod kątem przestrzegania reguł biznesowych. Dzisiaj przełożymy tę koncepcję na ekosystem Reactowy. Sprawimy, by nasze komponenty zawsze były używane zgodnie z naszymi oczekiwaniami.

Niekontrolowana liczba wariantów prowadzi do katastrofy

Budując generyczny komponent w reactcie, chcielibyśmy, żeby mnogość jego opcji nie rujnowała Developer Experience, jednocześnie wciąż zachowując swoją elastyczność.

Problem pojawia się, gdy nasz komponent bierze na siebie dużo odpowiedzialności, co prowadzi do kuriozalnych momentów, gdy musimy rozważyć rosnącą liczbę przypadków użycia, choć tak naprawdę chcielibśmy tego uniknąć.

Rozważmy przykład bannera, który może przyjmować odmienny wygląd, w zależności od wykorzystanego typu:

type Props = {
type: "success" | "info" | "warning" | "error";
label: string;
onRetry?: () => void;
onDismiss?: () => void;
};
const Banner = (props: Props) => {
if (props.type === "error" && props.onRetry) {
return (
<div className="banner bg-error">
{props.label}
<button onClick={props.onRetry}>Retry</button>
</div>
);
}
if (props.type === "warning" && props.onDismiss) {
return (
<div className="banner bg-warning">
{props.label} <button onClick={props.onDismiss}>x</button>
</div>
);
}
if (props.type === "success") {
return <div className="banner bg-success">{props.label}</div>;
}
return <div className="banner bg-info">{props.label}</div>;
};

Jak widać, nie jest to zbyt skomplikowana logika, jednak już teraz widać, że typowanie nie jest najlepsze. Wymagamy jedynie pola label, licząc, że developer domyśli się jak banner ma działa (bądź zaglądnie w źródła i sam doczyta szczegóły). Czego powinniśmy oczekiwać, gdy type przyjmie wartość success, a przekażemy wraz z nim do komponentu parametr onRetry? Możemy się poczuć zawiedzeni i oszukani, gdy na naszym bannerze jednak nie pojawi się przycisk Retry, etc.

Mamy więc tutaj liczne warianty, których obsługi nie przewidujemy. To prowadzi do powiększającego się chaosu w projekcie, a tym samym do niepotrzebnie zwiększonego complexity.

Ustalmy reguły!

Dzięki wykorzystaniu unii dyskryminowanych, możemy ustalić konkretne reguły zachowań naszego komponentu. Unikniemy dzięki temu nieporozumień na poziomie developmentu.

Zacznijmy od wyszczególnienia, o jakich regułach mówimy:

  • Jeśli banner jest typu error, powinien mieć możliwość akcji retry
  • Jeśli banner jest typu warning, powinien mieć możliwość akcji dismiss
  • Jeśli banner jest typu success lub info, nie ma dodatkowych akcji
  • Baner powinien przyjmować tylko takie akcje, które wykorzystuje
  • Każdy banner musi posiadać pole label

Zobaczmy, jak będzie to wyglądało w kodzie:

type Props = {
label: string;
} & (
| {
type: "success" | "info";
}
| {
type: "error";
onRetry: () => void;
}
| {
type: "warning";
onDismiss: () => void;
}
);

Tak, jak było to zaprezentowane w artykule o uniach, wykorzystaliśmy tutaj różne warianty, w zależności od pola type, który jest naszym wyróżnikiem. Jednocześnie zgrupowaliśmy te, które posiadają takie samo API (success i info). Korzystając też z intersekcji (&), dodaliśmy do wyselekcjowanego typu dodatkowe pole label, dzięki czemu, niezależnie od tego, jaki rodzaj bannera wyświetlimy, zawsze będziemy zobligowani do uzupełnienia treści.

Czas na implementację tego kodu w samym komponencie:

const Banner = (props: Props) => {
switch (props.type) {
case "error":
return (
<div className="banner bg-error">
{props.label}
<button onClick={props.onRetry}>Retry</button>
</div>
);
case "warning":
return (
<div className="banner bg-warning">
{props.label}
<button onClick={props.onDismiss}>x</button>
</div>
);
case "success":
return <div className="banner bg-success">{props.label}</div>;
case "info":
return <div className="banner bg-info">{props.label}</div>;
}
};

Skorzystaliśmy tym razem z konstrukcji switch, bo jedyne, co musimy sprawdzić, to typ. Całą resztą zajmuje się TypeScript. Dzięki wykorzystaniu unii, mamy pewność, że gdy pojawia się typ warning, dostaniemy także od developera props onDismiss, etc.

Gdybyśmy spróbowali wykorzystać np. onRetry w przypadku success, kompilator uprzejmie poinformowałby nas, że: Property 'onRetry' does not exist on type '{ label: string; } & { type: "success" | "info"; }'. W ten sposób bardzo szczelnie zapewniamy poprawne wykorzystanie naszych komponentów. A dodatkowo świetny feeling podczas tworzenia, ponieważ nasze “zestawy reguł” będą automatycznie podpowiadane za pomocą intellisense naszego IDE!

Wykorzystanie przycisku z błędnymi polami zakończy się natomiast błędem kompilatora:

/**
Type '{ type: "info"; onRetry: () => void; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' does not exist on type 'IntrinsicAttributes & { label: string; } & { type: "success" | "info"; }'
**/
const Sample = () => (
<Banner label="Info text" type="info" onRetry={() => {}} />
);

Jeśli jednak pominiemy któreś z obligatoryjnych pól, TypeScript również da nam o tym znać:

/**
Type '{ type: "error"; label: string; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' is missing in type '{ type: "error"; label: string; }' but required in type '{ type: "error"; onRetry: () => void; }'
**/
const Sample = () => <Banner label="Error text" type="error" />;

Unie znowu na ratunek

Jak widać, dzięki temu prostemu zabiegowi, jesteśmy w stanie zbudować samodokumentujący się kod, z bardzo intuicyjnymi podpowiedziami podczas korzystania. Każdy developer będzie Ci wdzięczny za tak dokładnie opisany komponent.

Photo by Patrick Robert Doyle


Tags

#typescript#javascript

Share

Previous Article
O tym, jak unia pozwala mi spać spokojnie

Robert Duraj

Software Engineer

Related Posts

Czy private jest private?
July 25, 2024
1 min
© 2024, All Rights Reserved.
Powered By

Social Media

linkedingithubtwitter