Od wersji 4.9 TypeScripta, która została wydana w listopadzie zeszłego roku, mamy możliwość korzystania z nowego słowa kluczowego satisfies
. Choć na pierwszy rzut oka trudno dokładnie znaleźć ciekawy przypadek użycia dla niego, to jednak po bliższym zapoznaniu, okazuje się, że może być całkiem pomocny. Zobaczmy, co da się z nim zrobić.
Artykuł powstał na bazie dokumentacji, issue z dokładnym opisem oraz materiałów znalezionych w sieci (spis na końcu tekstu). Jeśli niestraszny ci surowy techniczny angielski - odsyłam właśnie do tych źródeł, jako najbardziej rzetelnych. Jednak będzie mi bardzo miło, jeśli rozsiądziesz się wygodnie w fotelu i wyruszysz razem ze mną w tę podróż :).
Gdy zobaczymy jeden z pierwszych listningów z dokumentacji TS, zadamy sobie pewnie pytanie - do czego przyda nam się kolejny casting?
let p = { kind: "cat", meows: true } satisfies Animal;
satisfies
dołącza tutaj strukturalnie do słowa kluczowego as
, które umieszczamy na końcu definicji obiektu, więc naturalnym wydaje się, że będzie miał zbliżone do niego zastosowanie. Jeśli mielibyśmy szukać jakichkolwiek korelacji, to powiedzielibyśmy, że satisfies
naprawia to, co psuje as
. Nie rzucając jednak słów na wiatr, rozwińmy powyższy przykład.
Jakiś czas temu pisałem o uniach dyskryminowanych, jak bardzo pomagają mi w codziennej pracy. Natomiast jest z nimi pewien problem, jeśli zdecydowalibyśmy się skorzystać z castingu.
type Animal = { kind: "cat", meows: true } | { kind: 'dog', barks: true }const pet = { kind: "cat" } as Animalif (pet.kind === "cat") {pet.meows; // Powinno być true, ale dostajemy undefined}
Nie możemy być pewni tego, co znajduje się w obiekcie pet
, więc bazując na samym typie możemy sobie mocno utrudnić życie. Naprawmy to!
const pet = { kind: "cat" } satisfies Animal // Error: Type '{ kind: "cat"; }' does not satisfy the expected type 'Animal'.
Błąd rzucony. Jesteśmy bezpieczni. No i pięknie! 🎉 … ale nie. Bystre oko szybko może zauważyć, że to samo moglibyśmy zrobić używając const pet: Animal = { kind: "cat"}
. Brawo, pełna zgoda. Tutaj dochodzimy do głównego atutu nowego słowa kluczowego, czyli tego, co on tak właściwie robi z typami naszych obiektów.
satisfies
daje nam ogromne możliwości, jeśli chodzi o typowanie naszych obiektów. W TypeScript typy mogą być wyliczone (inferencja typów), mogą też być zadeklarowane razem z obiektem, ale też możemy sami podpowiedzieć kompilatorowi o jaki typ nam chodziło, korzystając właśnie z castingu. Każda z tych metod ma swoje wady i zalety, do tej listy metod dochodzi teraz satisfies
- mechanizm, który zmusza kompilator do sprawdzenia kandydującego obiektu, czy spełnia on wymagania podanego typu oraz - co najważniejsze - zachowując jego pierwotnie wyliczony typ.
Przyjrzyjmy się kolejnemu przykładowi:
const firstName: string = "Anakin"type FirstName = typeof firstName // typ FirstName to string// vs.const firstName = "Anakin"type FirstName = typeof firstName // typ FirstName w tym przypadku to "Anakin"
W pierwszym przypadku korzystamy z deklaracji typu, jednocześnie rozszerzając go do ogólnego typu, jakim jest string
. W drugim przykładzie FirstName
to literał "Anakin"
. Podczas definicji obiektów TypeScript będzie zachowywał się podobnie. Posłużmy się przykładem z dokumentacji, paleta kolorów, czyli słownik obiektów z wartościami RGB. Jeśli zadeklarujemy typ obiektu palette
, będziemy mieli bardzo szeroką definicję jego zawartości:
type Color = { r: number, g: number, b: number };const palette: Record<string, Color> = {white: { r: 255, g: 255, b: 255},black: { r: 0, g: 0, b: 0 },blue: { r: 0, g: 0, b: 255 },};
Taki kod pozwala swobodnie skorzystać z pola palette.purple.b
, które (co wiemy) nie istnieje, ale zgadza się z podanym typem. TS nam o tym nie powie, ale rozwścieczony klient na pewno. Możemy usunąć niewygodny zapis : Record<string, Color>
i pozwolić kompilatorowi na wyliczenie tego typu do postaci:
{white: {r: number;g: number;b: number;};black: {r: number;g: number;b: number;};blue: {r: number;g: number;b: number;}}
Wydaje się, że rozwiązuje nam to nasz problem, ponieważ od teraz palette.purple.b
będzie okraszone błędem TS. Problem w tym, że jeśli popełnimy literówkę w nazwie pola { black: { r: 0, g: 0: d: 0 } }
, to kompilator przymknie na to oko i wyliczy typ z literówką.
Potrzebujemy więc sposobu, by wilk był syty. I jak się domyślacie - to miejsce na satisfies
. Oto, co następuje:
type Color = { r: number, g: number, b: number };const palette = {white: { r: 255, g: 255, b: 255},black: { r: 0, g: 0, d: 0 }, // Error: Type '{ r: number; g: number; d: number; }' is not assignable to type 'Color'.blue: { r: 0, g: 0, b: 255 },} satisfies Record<string, Color>;
Pięknie! Co jednak się zmienia w stosunku do const palette: Record<string, Color> = ...
? A to, że typem dla palette
(wyliczonym, a następnie “usatysfakcjonowanym” przez Record
) jest ten, który wrzuciliśmy powyżej, z wyszczególnionym każdym jednym kolorem i ich (poprawnymi!) polami RGB.
Dzięki temu, typ naszej palety jest dokładnie zawężony i próba wywołania palette.purple.g
zakończy się jazgotem kompilatora. Jesteśmy bezpieczni dzięki właściwości property value conformance dostarczanej przez satisfies
.
To jednak nie koniec ciekawostek związanych z nowym operatorem. W podobny sposób możemy upewniać się o zgodności nie tylko wartości, ale i kluczy.
Wracając do naszej palety, możemy zdefiniować sobie zestaw dokładnych kolorów, które chcemy móc wykorzystać, żeby nie popełnić literówki w nazwie koloru:
type ColorNames = 'white' | 'black' | 'blue'
Tak opisane klucze wstawimy do naszego utility type’u Record
.
const palette = {white: { r: 255, g: 255, b: 255},black: { r: 0, g: 0, d: 0 }, // Error: Type '{ r: number; g: number; d: number; }' is not assignable to type 'Color'.blue: { r: 0, g: 0, b: 255 },} satisfies Record<ColorNames, Color>;
Dzięki takiemu zapisowi satisfies
sprawdzi przy pomocy właściwości property name constraining oraz property name fulfillment czy:
ColorNames
ColorNames
(!!)ColorNames
(!!!)Zobaczcie jak dokładnie jesteśmy w stanie zabezpieczyć nasz kod za pomocą prostego operatora!
Czasem potrzebujemy zdefiniować jakiś obiekt, mając na uwadze, że nie wszystkie pola tego typu będą uzupełnione na samym początku. Pomaga nam w tym utility type jakim jest Partial
. Jest z nim jednak pewien problem:
type User = { id: number, name: string };const newUser: Partial<User> = { name: "Luke" };console.log(newUser.name.toUpperCase()); // Error: 'newUser.name' is possibly 'undefined'const newUserId = newUser.id; // 👌
Patrząc na kod moglibyśmy się kłócić z kompilatorem, ponieważ newUser.name
wykorzystany w console.log
istnieje, widzimy go dokładnie w linii powyżej. TS jednak uważa, że skoro jest to typ Partial<User>
, to nie ma on pewności, co do tego, czy na pewno newUser.name
jest zadeklarowany. Odwrotna sytuacja występuję linię dalej - możemy przypisać do zmiennej newUserId
wartość newUser.id
, pomimo tego, że ona nie istnieje!
Przed takimi pomyłkami chroni nas satisfies
oraz jego właściwość Optional Member Conformance:
const newUser = { name: "Luke" } satisfies Partial<User>;
Wymusi on bowiem sprawdzenie wartości przez kompilator. Kompilator przeczyta, co napisaliśmy, odpowiednio otypuje newUser
i w kolejnych liniach pozwoli wykonać newUser.name.toUpperCase()
, natomiast zwróci błąd, gdy spróbujemy odwołać się do newUser.id
.
Nie da się ukryć, że odkrycie sekretów satisfies
nie jest oczywiste. Jednak, gdy zejdziemy na odpowiedni poziom rozumienia tego operatora, to nagle dostrzegamy więcej i więcej przypadków użycia, które pojawiają się w codziennym kodowaniu. Dla mnie rewelacyjnym przykładem jest ten opisany na stronie builder.io, dotyczący routingu. Odsyłam do artykułu po szczegóły jednak jako zajawkę zostawię taki oto fragment:
type Route = { path: string; children?: Routes }type Routes = Record<string, Route>const routes = {Auth: {path: "/auth",},} satisfies Routesroutes.Auth.path. // ✅routes.Auth.children // ❌ routes.Auth has no property `children`routes.App.path // ❌ routes.App doesn't exist
How cool is that, right?! ♥️
Źródła: