Sytuacja kobiet w IT w 2024 roku
12.06.20217 min
Adrian Kałuziński

Adrian KałuzińskiSoftware Engineer

Pułapki w typach JavaScript - na co musisz uważać

Poznaj część z pułapek czających się podczas codziennej pracy z językiem JavaScript.

Pułapki w typach JavaScript - na co musisz uważać

Standard ECMAScript, na którym opiera się JavaScript, był początkowo projektowany z myślą o pisaniu prostych skryptów. Od swoich narodzin w 1995 roku wyewoluował i współcześnie jest pełnoprawnym językiem ogólnego zastosowania [1]. Wprowadził pewne ułatwienia, takie jak niejawne konwersje typów (ang. type coercion/type casting). Ułatwienia te są jednak pełne pułapek tylko czyhających na naszą nieuwagę, by doprowadzić do bólu głowy programistów, przyzwyczajonych do pracy w językach ze statyczną kontrolą typów (m. in. Java/C++/Swift). Chciałbym przedstawić Wam część z tych haczyków.

W przeciwieństwie do języków statycznie typowanych, typy zmiennych w językach dynamicznie typowanych nie są sprawdzane przed uruchomieniem programu i mogą zostać zmienione podczas jego działania. W rezultacie często błędy ujawniają się dopiero po dłuższym czasie działania aplikacji np. w momencie otrzymania nietypowych danych. 

Jawne konwersje typów

Jawna konwersja typów? Co to znaczy? Po kolei. Typ jest to w skrócie opis możliwych wartości, które może przyjąć dana stała, zmienna, argument lub wynik funkcji. Typ konkretnej zmiennej można sprawdzić wykonaniem operatora typeof wartośćDoSprawdzenia. 


JavaScript dostarcza nam następujące typy:

Typy prymitywne (proste):

  • undefined
  • boolean
  • number
  • bigint
  • string
  • symbol
  • null(jest to osobny typ, ale typeof null === "object" [2])


Typy strukturalne/złożone:

  • Typ object określa całą resztę struktur takich jak tablica czy mapa. 
  • function jest szczególnym przypadkiem typu object, który zasłużył na osobną wartość operatora typeof (() => {}) === "function"


Typów nie ma ich zbyt wiele, a mimo to potrafią uziemić nas nad debuggerem na długie minuty. Konwersja typów jest to proces zmiany jednego typu na inny. 

Jeśli chcemy dokonać jawnej konwersji, na przykład do dużej liczby (BigInt), musimy zapisać to wprost. Określamy jaki typ chcemy otrzymać, a nawiasie podajemy wartość do przekonwertowania. String(123), Boolean(5), BigInt("    44 ").

Zwykle konwersje zachowują się tak, jak podpowiada intuicja.

String(555)  // "555"
String(-10)   // "-10"
String(null)  //  "null"
String(true)  //  "true"

Boolean(4)       // true
Boolean(0)        // false     
Boolean(NaN)     // false
Boolean(null)       // false
Boolean(undefined) // false

Number(7) 		    // 7	
Number(true)        // 1
Number("true")      // NaN
Number(false)       // 0
Number(null)        // 0
Number(); 		    // 0
Number("   -31.31") // -31.31

Truthy/Falsy

Czasami przekazując, powinniśmy się mieć na baczności, ponieważ niektóre konwersje mogą zaskoczyć:

Boolean([])  //true 


Wartość falsy jest to wartość, która przy ewaluacji (na przykład w instrukcji warunkowej) będzie równa false. Jest dokładnie sześć falsy wartości w JavaScript: 

  • null
  • 0
  • ""
  • false
  • undefined


Truthy jest wszystko, to nie jest falsy. Pusta tablica [] jest obiektem, a obiekty są zawsze uznawane za truthy. 

Number(undefined);  // NaN


To nie jest oczywiste, zwłaszcza, że Number() === 0, ale po prostu tak zostało zdefiniowane w specyfikacji języka i powinniśmy to zaakceptować [3].

Niejawna konwersja typów

Konwersja niejawna ma miejsce kiedy próbujemy wykonać jakieś operacje, przekazując inny typ, niż jest oczekiwany. W JavaScript możliwe są tylko 3 niejawne (dziejące się bez naszego żądania) konwersje: 

  • do wartości liczbowej (Boolean)
  • do łańcucha tekstowego (String)
  • do liczby zmiennoprzecinkowej (Number)


Przykłady niejawnych konwersji mogą wyglądać tak:

  • 4 > "1" ("1" zostanie zmienione na number i w wyniku otrzymamy  true)
  • "7" + 5(powstanie string "75")
  • if ("kebab") {} (Niepusty napis jest truthy więc "kebab" zostanie zmienione na true)
  • "4" || 2 && true(w tle wykonuje się konwersja "4" i 2 do typu boolean, mimo iż całe wyrażenie zwróci "4")
  • [] + 777 (Mimo iż żadna z wartości nie jest łańcuchem tekstowym, to wynikiem wyrażenia jest string "777". To może zaskoczyć!)


Częstą praktyką jest używanie operatorów + oraz ! do świadomego wykonania konwersji kolejno do number i boolean.

+"3" === 3 
!!"false" === false


Uwaga. Jedynym typem, który nie podlega niejawnej konwersji do typu number, jest Symbol. Próba sprawdzenia, czy Symbol(3) > 3 skończy się błędem Uncaught TypeError: can't convert symbol to number.

Null

Bywa, że zmienna, którą obsługuje Twój kod, jest typu null. Spróbujmy porównać null z zerem.

null < 0  // false
null > 0 // false
null == 0 // false
null <= 0 // true ?
null >= 0 // true ?
null <= 0 && null >= 0 // true


Czekaj, co? null nie jest zerem, nie jest mniejszy od zera, nie jest większy od zera, ale jest mniejszy lub równy zero, oraz jest większy lub równy zero. Dokładny opis algorytmu porównywania dwóch wartości znajdziesz w specyfikacji ECMAScript [5], a analizę tego dziwnego zachowania na blogu [6].

Być może zauważyliście, że Number(null) === 0, jednak ta konwersja nie zostanie wykonana automatycznie i należy zachować ostrożność, porównując wartości liczbowe ze zmienną, która może być przyjąć wartość null.

Wartości logiczne

Pamiętajcie także, że niejawna konwersja będzie miała miejsce w przypadku użycia pewnych operatów na wartościach logicznych. Jak myślicie, jaki będzie wynik poniższych operacji?

true + false => ???
true + true => ???
true !== 1 => ???
true + false === 1


Operator + służy głównie do dodawania liczb, ale kiedy napotyka na wartości logiczne, dokonuje zamiany ich na liczby. Obie wartości logiczne zostały przekonwertowane kolejno na 1 i 0, a więc ich suma to 1.

true + true === 2 // true


Tutaj zachowanie jest podobne, jak w poprzednim przypadku. Obie wartości logiczne zostały przekonwertowane kolejno na 1 i zsumowane, a więc wynik operacji to 2.

true !== 1 // true


Skoro suma dwóch true daje, to czemu true nie jest równy 1? Dokładnie tak ma być, gdyż użyłem porównania !==

Operatory równości

Istnieją dwa operatory równości (== vs. ===) i dwa różności (!= vs. !== ). Dwa z nich (== oraz !=) sprawdzają, czy wartości po przekonwertowaniu do wspólnego typu są równe/różne.

if( true == 1 ) {
	console.log('to jest prawda');
}

if ( null == undefined ) {
   console.log('to jest trochę szokująca prawda');
}

if( "" == false ) {
  console.log('to też jest prawda');
}

if( [1] == true ) {
  console.log('i to również');
}


Z kolei  ( !== vs. === ) pozwalają na porównanie wartości i ich typów. Oznacza to, że wartości nie będą równe jeśli ich typy się nie zgadzają.

true === 1 //false 
"" === false //false
[1] === true //false
true !== 1 // true


Operatory te są bardzo dobrym zabezpieczeniem przed niektórymi z dziwactw wynikających z niejawnych konwersji. W większości przypadków radzę Wam używać !== i ===

A jakie będą wyniki inkrementacji wartości logicznej?

let i = true; 
i++; 
// i === ???


Po wykonaniu i będzie równe 2. Nie powinno być wielkiego zaskoczenia. i++ będzie oznaczało i = true + 1. Jak już wiesz, Number(true) === 1, więc nasze true zostanie niejawnie przekonwertowane na 1.

A co z bardzo podobnym przypadkiem?

let j = true++; 
//j === ???


Można pomyśleć, że true++ zachowa się podobnie do poprzedniego przykładu, lecz tym razem wyjątkowo JavaScript zwróci błąd SyntaxError: invalid increment/decrement operand. Operator postinkrementacji oczekuje zmiennej/stałej, którą można przekonwertować do typu liczbowego. 

Tablice

Czy wiecie, jaki będzie wynik poniższych operacji?

[] + [] => ???
[1, 2, 3] + [4, 5, 6] => ???
[] == [] => ???
{} + [] => ???
[] + {} => ???
[] + { a:'a'} => ???


Podaję odpowiedzi.

[] + [] === ""
[1, 2, 3] + [4, 5, 6] === "1,2,34,5,6"


W skrócie JavaScript niejawnie konwertuje tablice do typu prymitywnego (string) przez wykonanie Array.prototype.toString np. [1, 2, 3].toString() === "1,2,3" [] == [] // false

Tak, dwie puste tablice nie są sobie równe. Jeśli ktoś uczący się JavaScript chciałby sprawdzić w poniższy sposób, czy tablica jest pusta, to nie zadziała.

if (someArray === []){
	console.log('Nigdy tutaj nie wejdę.');
}


Wystąpiłoby porównanie referencji do dwóch obiektów, a nie ich wartości. Do sprawdzania, czy tablica jest pusta należy wykonać someArray.length === 0, albo skorzystać z wiedzy, że someArray.length przekazane do instrukcji warunkowej zrobi niejawną konwersję liczby elementów someArray do wartości logicznej.

if (!someArray.length){
	console.log('Tutaj wejdę jeśli tablica jest pusta.');
}

[] + {} === "[object Object]"
[] + { name: 'Janek'} === "[object Object]".


Warto zapamiętać, zasady konwersji obiektów:

  • Number({}) => NaN - nie da się ukryć, że obiekt nie jest liczbą
  • Boolean({}) => true- obiekty są truthy
  • String({}) => "[object Object]"


A co, jeśli zmienimy kolejność operatorów dodawania?

{} + [] === 0 [!!!]  - Ma tutaj miejsce interpretacja {} jako pustego bloku [4]. Wartość zwrócona przez pusty blok jest pusta, więc operacja to tak naprawdę +[]. Jest to  konwersja pustej tablicy na liczbę (Number([].toString())), co daje 0

NaN

Szczególnym przypadkiem wartości liczbowej jest NaN (Not-a-Number). 

typeof NaN === "number"


Dlaczego twórcy języka zdecydowali się potraktować Not-a-Number jako number, to osobny temat na rozważania filozoficzne. NaN może się nam zdarzyć na przykład, kiedy spróbujemy zmienić tekst pobrany z formularza na liczbę:

const yearOfBirth = Number(" trololo ")
	console.log(yearOfBirth) => NaN


Ktoś mógłby chcieć sprawdzić, czy przekazana wartość ma sens.

if (yearOfBirth === NaN) {
	throw Error("Proszę podać poprawną wartość roku");
}


I zonk. Okazuje się, że błąd nie zostałby zgłoszony, ponieważ

NaN === NaN ⇒ false
	NaN == NaN ⇒ false


NaN nie jest równe niczemu, nawet sobie. W celu sprawdzenia, czy jakaś wartość wynosi NaN, należy wykonać funkcję isNaN(yearOfBirth).

if ( isNaN(yearOfBirth) ){
	throw Error("Proszę podać poprawną wartość roku");
}

Podsumowanie

Wykorzystanie ukrytych konwersji pozwala na pisanie kodu, który jest zwięzły, ale może wprowadzić trudne do znalezienia błędy.

Co możecie zrobić, żeby uniknąć problemów?

  • Jawnie konwertujcie stałe i zmienne do typów, na których chcesz pracować
  • Używajcie operatorów === i !== zamiast == i !=
  • Dokładnie testujcie zachowanie kodu dla skrajnych wartości: undefined, "", [], null. Warto rzucić okiem na tablicę równości [7] przed porównywaniem ich
  • Zainteresujcie się językiem TypeScript. Jest to nadzbiór języka JavaScript dodający m.in. możliwość statycznego typowania [8]. Dodanie typów może oznaczać godziny zaoszczędzone na debugowaniu.


Gorąco zachęcam do samodzielnego eksperymentowania z operacjami konwersji w narzędziach deweloperskich przeglądarki. Może znajdziesz coś jeszcze ciekawszego?

console.log((!!!true + [])[+[]] + (typeof (() => {}))[+!+[]] +([] + null)[+[]]) // "fun"
<p>Loading...</p>