Jak działa coercion w JavaScript
JavaScript co chwilę po cichu konwertuje jeden typ na drugi. Notatka o tym, dlaczego 1 + "2" to "12", a 1 - "2" to -1, i co tak naprawdę robi silnik między linijkami.
Pewnie nie raz widziałeś coś takiego:
1 + "2"; // "12"
1 - "2"; // -1
"5" * "3"; // 15
[] + {}; // "[object Object]"
if ("0") {
} // wchodzi - "0" jest prawdąWygląda to chaotycznie, ale pod spodem za każdym razem dzieje się dokładnie to samo: silnik dostał wartość nie tego typu, którego potrzebował, i po cichu ją skonwertował. Ten proces nazywa się coercion (po polsku: koercja). Dzieje się stale: w wyrażeniach z + albo ==, w warunku if, w template literalach `${...}`, w argumentach Number(...). Pod spodem siedzą cztery wewnętrzne operacje silnika - i nimi się tutaj zajmiemy.
#Kiedy silnik dostaje obiekt zamiast wartości
Najciekawszy przypadek to ten, w którym po którejś stronie operacji jest obiekt - tablica, {}, Date, cokolwiek. Silnik nie umie dodać obiektu do liczby ani porównać go z stringiem, więc najpierw musi zamienić go na coś prostszego. Robi to przez wewnętrzną operację o nazwie ToPrimitive.
ToPrimitive działa jak rozmowa z obiektem: pyta go, czy ma wersję prymitywną. Obiekt może odpowiedzieć przez dwie metody:
valueOf()- „daj mi swoją wartość liczbową”.toString()- „daj mi swoją wersję tekstową”.
Silnik wywołuje je po kolei i bierze pierwszy wynik, który jest prymitywem (liczbą, stringiem, booleanem). Jeśli któraś metoda zwróci znów obiekt, ten wynik jest pomijany. Jeśli żadna nie zwróci prymitywu - leci TypeError.
Pytanie tylko: która metoda pierwsza? Zależy od tego, czego silnik akurat potrzebuje. Specyfikacja nazywa tę informację hint:
hint = "number"- kiedy silnik potrzebuje liczby (-,*,/,<,>,Number(x)). NajpierwvalueOf, potemtoString.hint = "string"- kiedy silnik potrzebuje stringa (template literal,String(x)). Odwrotnie: najpierwtoString, potemvalueOf.hint = "default"- dla==i dla+z dwoma operandami (np.obj + 1,a + b). Większość obiektów traktuje to jak"number". Wyjątkiem jestDate, który mapuje"default"na"string"- bo dla daty intuicyjniej jest dostać tekst niż timestamp.
Najłatwiej zobaczyć ten mechanizm na obiekcie, w którym sami zdefiniujemy obie metody. Niech zwracają wyraźnie różne wartości, żeby po wyniku poznać, którą silnik wywołał pierwszą:
const obj = {
valueOf() {
return 42;
},
toString() {
return "ala";
},
};
Number(obj); // 42 -> Number() prosi o hint "number", valueOf idzie pierwszy
`${obj}`; // "ala" -> template literal prosi o hint "string", toString idzie pierwszyTen sam obiekt, dwa różne hinty, dwa różne wyniki. Gdyby któraś z metod zwracała obiekt zamiast prymitywa (czyli czegoś innego niż liczba, string lub boolean), silnik by ją pominął i sięgnął po drugą. Gdyby obie zwróciły obiekt - leciałby TypeError.
Cała „magia” [] + {} siedzi właśnie tutaj: + z dwoma operandami to hint "default", czyli "number". Silnik pyta [] o wartość liczbową, dostaje "". Pyta {} o to samo, dostaje "[object Object]". Żadne z dwojga nie jest liczbą, ale są prymitywami - więc silnik je skleja jak stringi. Dziwny wynik, deterministyczne kroki.
#Trzy konwersje, których używasz codziennie
Obok ToPrimitive silnik definiuje trzy operacje, które realizują konwersję do konkretnego typu. Wywołują się jawnie, kiedy piszesz Number(x), String(x) albo Boolean(x) - i niejawnie wszędzie tam, gdzie typ trzeba dopasować w locie (+, ==, if, template literal):
ToString(x)- daje stringa. Jeślixto obiekt, najpierw przepuszcza go przezToPrimitivez hintem"string", dopiero potem zamienia wynik na string.ToNumber(x)- daje liczbę. To samo, ale z hintem"number".ToBoolean(x)- daje boolean. Jako jedyna z trzech nie używaToPrimitive.
Ostatni punkt to mała pułapka. ToBoolean nie pyta obiektu o nic - sprawdza tylko, czy wartość znajduje się na krótkiej liście wartości fałszywych zapisanej wprost w specyfikacji: 0, "", null, undefined, NaN, 0n. Wszystko poza tą listą to true. Dlatego:
Boolean({}); // true - {} nie jest na liście falsy
Boolean([]); // true - [] też nie jest, choć jest puste
Boolean("0"); // true - "0" to niepusty stringTablica i obiekt, choć puste, dla ToBoolean są normalnymi wartościami referencyjnymi. Dla ToString byłyby "" i "[object Object]", dla ToNumber 0 i NaN - ale ToBoolean nawet do nich nie zagląda.
#Explicit i implicit - dwa style coercion
W praktyce coercion robisz w kodzie na dwa sposoby:
- Explicit - sam mówisz wprost, co chcesz:
Number("42"),String(true),Boolean(0). Intencja widoczna w jednym spojrzeniu. - Implicit - dzieje się samo, w tle:
"5" - 2,if (obj),`data: ${date}`. Krócej, czytelniej, ale wymaga, żebyś wiedział, jakich typów się spodziewasz po obu stronach.
Implicit coercion ma złą reputację, bo łatwo o nim mówić jako o „magii”, która zaskakuje. Ale pod spodem nie ma żadnej magii - są te same cztery operacje, które właśnie omówiliśmy. Cała różnica polega na tym, czy ToNumber woła silnik, czy ty.
Reguła nie brzmi „nie używaj implicit coercion”, brzmi „znaj swoje typy”. Kiedy wiesz, co masz po obu stronach == albo +, silnik zrobi sensowną rzecz, a implicit coercion staje się skrótem, nie pułapką. Kiedy nie wiesz, sięgaj po explicit, żeby kod sam tłumaczył intencję.