На первый взгляд тема типов данных и преобразований может показаться легкой.

Обычно она изучается в самом начале погружения в JavaScript. Однако в этих темах есть неочевидные подводные камни, которые знает далеко не каждый разработчик.

В этой статье мы рассмотрим особенности типов данных и преобразований, которые многие пропустили.

typeof

JavaScript имеет 8 встроенных типов данных:

  1. null

  2. undefined

  3. boolean

  4. number

  5. string

  6. object

  7. symbol

  8. BigInt

Подробности о каждом типе данных вы можете причитать в любой документации.

Значения typeof отличаются:

  1. ”undefined”

  2. ”boolean”

  3. ”number”

  4. ”string”

  5. object”

  6. ”symbol”

  7. ”function”

  8. "bigint"

Оператор typeof возвращает строку, указывающую тип операнда.

Операнд – то, к чему применяется оператор. Например, в умножении 5 * 2 есть два операнда: левый операнд равен 5, а правый операнд равен 2.

Оператор typeof напрямую не коррелирует со встроенными типами!

typeof null

Давайте рассмотрим пример:

const a = null;
console.log(!a && typeof a === "object");

В консоль будет выведено значение true.

Такой результат будет из-за того, что JavaScript имеет старый баг.

typeof null возвращает "object".

Этот баг существует уже много лет и вероятнее всего уже никогда не будет исправлен. Это связано с тем, что написано слишком много кода, который полагается на это ошибочное поведение.

typeof function

Посмотрим пример:

Что будет выведено в консоль?

const x = function() {}
console.log(x.length) // 0
const y = function(a, b, c) {}
console.log(y.length) // 3

У многих людей, которые не встречали такого вопроса ранее, может возникнуть недоумение. Учитывая, что typeof y и typeof x возвращает “function”, кто-то может ожидать, что функция является одним из встроенных типов в JS. На самом деле, согласно спецификации, функция - это подтип объекта. Благодаря этому можно проверить количество аргументов у функции через .length

typeof NaN

Важно запомнить особенности NaN:

  1. NaN никогда не равен сам себе независимо от того используем мы == или ===.

    NaN === NaN // false
    NaN == NaN // false
  2. typeof NaN всегда возвращает “number” . Это может показаться странным из-за того, что NaN - не число, которое является числом. NaN все еще числовой тип несмотря на этот факт.

  3. window.isNaN вернет true только для фактических значений NaN, когда результат просто не число.

    window.isNaN(2 / "Dave") // true
    window.isNaN("Dave") // true

    window.isNaN преобразует аргумент в number и возвращает true , если результат будет равен NaN

  4. Number.isNaN был добавлен в ES6. Number.isNaN вернет true только для тех значений, которые не являются числами, например, применимо к строке, будет возвращено false.

    Number.isNaN(2 / "Dave") // true
    Number.isNaN("Dave") // false

    Number.isNaN производит приведение типов, в то время как window.isNaN не делает приведение.

Значение vs Ссылка

Вспомним простые значения в JS:

  1. null

  2. string

  3. boolean

  4. number

  5. symbol

Комплексные значения:

  1. Массивы

  2. Объекты

  3. Функции

Простые значения в JS имутабельные. Комплексные значения мутабельные.

Сначала повторим разницу между мутабельными и имутабельными данными.

// Пример имутабельности чисел:
let a = 1
let b = a
b++
console.log(a) // 1
console.log(b) // 2
// Пример мутабельности:
let x = [1, 2, 3]
let y = x
y.push(4)
console.log(x) // [1, 2, 3, 4]
console.log(y) // [1, 2, 3, 4]
x.push(5)
console.log(x) // [1, 2, 3, 4, 5]
console.log(y) // [1, 2, 3, 4, 5]

В примере мутабельности мы определили массив x. Константе y мы присвоили ссылку на х. Когда мы модифицируем массив x , мы также модифицируем и y.

При работе с имутабельными данными такого эффекта не происходит. Это важно запомнить!

Мы рассмотрели пример с числами. Давайте взглянем на пример со строками:

// Имутабельные строки:
let a = "hello"
a[2] = "Z"
console.log(a) // "Hello"

В данном примере не произойдет изменение строки!

Если вы хотите изменить строку, то вам придется создать новую переменную.

С использованием метода .toUpperCase() ситуация будет отличаться.

a.toUpperCase()
console.log(a) // "HELLO"

Метод .toUpperCase() возвращает новую строку и присваивает переменной a.

Взглянем, как ведут себя массивы со строками:

// Мутабельные массивы:
let b = ["h", "e", "l", "l", "o"]
b[2] = "Z"
console.log(b) // ["h", "e", "Z", "l", "o"]

Тут мы получили модифицированный массив b.

После повторения разницы между мутабельными и имутабельными данными может возникнуть вопрос: “Откуда у примитивных данных есть полезные методы вроде .toUpperCase() ?”

Если движок JavaScript встречает запись подобную "hello".toUpperCase() и у нас есть примитив, то мы вызываем у него метод. В таком случае вокруг примитива создается обертка в виде объекта, у которого как раз есть методы. После выполнения инструкции обертка удаляется и у нас снова остается примитивное значение.

Давайте рассмотрим легкий пример:

let a = [0, 1]
let b = a
b[0] = "a"
console.log(a) // ["a ", 1]

После повторения теории результат выполнения будет очевидным.

Но существует особенность в другом похожем примере:

let a = [0, 1]
let b = a
b = ["a", "b"]
console.log(a[0]) // 0

Переменной a мы присвоили массив.

Переменной b мы присвоили ссылку на переменную а, а затем переменной b присвоили новый массив.

В момент последнего присвоения старая ссылка была удалена!

Если мы создали новую ссылку в b, то мы уже не можем, модифицируя b , изменять и a.

Еще раз:

let a = [0, 1]
let b = a // Создается ссылка
b = ["a", "b"] // Создается НОВАЯ ссылка на массив

Сравнение типов

Преобразование может быть явным, когда мы целенаправленно приводим один тип к другому, либо неявным, когда приведение типа происходит автоматически без явных команд.

String("123") // Явное преобразование
123 + "" // неявное преобразование

В JavaScript преобразование всегда приводит к 3м типам:

  • к строке

  • к числу

  • к логическому значению (true / false)

Приведение к строке

String(null) // "null"
String(undefined) // "undefined"
String(true) // "true"
String(false) // "false"
String(1) // "1"
String(NaN) // "NaN"
String(10000000000 * 900000000000) // "9e+21"
String({}) // "[object Object]"
String({ name: "Ivan" }) // "[object Object]"
String([]) // "" 
String([1, 2, 3]) // "1,2,3"

В данном примере преобразование происходит очевидным образом.

Приведение к числу

Number(null) // 0
Number(undefined) // NaN
Number(true) // 1
Number(false) // 0
Number(1) // 1
Number(NaN) // NaN
Number(10000000000 * 900000000000) // 9e+21
Number({}) // NaN
Number({ name: "Ivan" }) // NaN
Number([]) // 0
Number([1, 2, 3]) // NaN
Number("Ivan") // NaN
Number("0") // 0
Number("123") // 123

Тут есть исключения, которые нужно помнить:

  • Number(null) приводится к 0

  • Number(undefined) приводится к NaN

  • Пустой массив Number([]) приводится к 0

  • Не пустой массив Number([1, 2, 3]) приводится к NaN

Приведение к логическому типу

// Ложные значения
Boolean(null) // false
Boolean(undefined) // false
Boolean(NaN) // false
Boolean(-0) // false
Boolean(+0) // false
Boolean("") // false

// Истинные значения
Boolean(1) // true
Boolean(-1) // true
Boolean(10000000000 * 900000000000) // true
Boolean({}) // true
Boolean({ name: "Ivan" }) // true
Boolean([]) // true
Boolean([1, 2, 3]) // true
Boolean(() => {}) // true
Boolean("Ivan") // true
Boolean("0") // true

В этом примере стоит заострить внимание на объектах и массивах.

Пустая функция, объект или массив приведет к true.

Приведение комплексных данных

Комплексные данные, такие как объекты и массивы, сначала будут преобразованы в их примитивные значения, а уже потом это значение будет преобразовано в число.

Разберем более подробно. Если у объекта доступен метод .valueOf , который возвращает примитивное значение, то оно будет использоваться для приведения к числу, а если нет, то будет использоваться метод .toString().

Если ни одна операция не может предоставить примитивное значение, то выдается ошибка “Type Error”

let x = {}
x.valueOf = () => 22
console.log(Number(x)) // 22
let y = []
y.toString = () => '22'
console.log(Number(y)) // 22
let z = {}
z.valueOf() // {} (не примитив)
z.toString() // "[object Object]" (приводит объект к строке)
Number(z) // NaN

Давайте коснемся логических операторов прежде чем продолжить дальше:

Как вы думаете что будет выведено в консоль?

let obj = {
	a: {
		b: "c"
	}
}
console.log(obj.a && obj.a.b)

Казалось бы простой вопрос, но не все ответят правильно.

В консоль будет выведено с. Особенно для тех, кто пришел из других языков такой результат будет неочевиден.

Вспомним как работают логические операторы:

let a = 1
let b = "a"
let c = null
console.log(a && b) // "a"
console.log(a || b) // 1
console.log(b || c) // null
console.log(c || a) // 1

Если оба операнда истины, тогда будет возвращен последний операнд.

Строгое сравнение и сравнение с приведением типов

Обычно считается, что === использует “строгое” сравнение, и сравнивает типы, а == нет.

Если говорить более корректно, == позволяет делать приведение типов, тогда как === не разрешает.

Таблицы ниже показывают в результатах между == и ===

==

===

Неявное приведение между строкой и числом

Можно неявно привести строку к числу, используя оператор +.

В JS оператор + используется как для сложения чисел, так и для конкатенации строк.

Оператор + выполняет операцию .toPrimitive над значением левой и правой стороны.

Метод .toPrimitive вызывает valueOf у значения. Если одно из значений является строкой, то он их объединяет.

Также существует небольшая разница между неявным приведением числа к строке с помощью + и явным с помощью String().

+ вызывает valueOf , в то время как явный метод вызывает toString

Искусственный пример. Не берите его в свой код. Пример только для наглядности:

a = { valueof: () => 22, toString: () => 44 }
String(a) // 44
a + '" // 22

Алгоритмы сравнения

Нас интересует, что происходит, когда boolean находится по обе стороны от ==.

console.log("22" == true) // false
console.log("22" == 1) // false
console.log(22 == 1) // false

Вы могли ожидать, что "22" == true вернет true, т.к. “22” является истинным значением, но фактически результат будет false.

Это происходит из-за того, что значение true приводится к числу. Результат выполнения будет 1. Далее "22" приводится к числу 22. В конце идет сравнение 22 == 1, где и возвращается false.

Задачи на собеседованиях

Мы готовы рассмотреть интересные примеры, которые встречаются на собеседованиях.

console.log(false == "0") // true
// false приведен к 0
// "0" приведен к 0
// 0 === 0
console.log(false == 0) // true
// false приведен к 0
// 0 === 0
console.log(false == "") // true
// false приведен к 0
// "" приведен к 0
// 0 === 0
console.log(false == []) // true
// false приведен к 0
// [] это объект так что вызывается ToPrimitive
// valueOf() попробует получить примитивное значение
// [].valueOf() приведет к [], что не является примитивным значением
// При вызове [].toString() получим ""
// "" будет приведео к числу 0
// 0 === 0
console.log("" == 0) // true
// "" будет приведено к 0
// 0 === 0
console.log("" == []) // true
// [] это объект так что вызывается ToPrimitive
// valueOf() попробует получить примитивное значение
// [].valueOf() приведет к [], что не является примитивным значением
// При вызове [].toString() получим ""
// "" === ""
console.log(0 == []) // true
// [] это объект так что вызывается ToPrimitive
// valueOf() попробует получить примитивное значение
// [].valueOf() приведет к [], что не является примитивным значением
// При вызове [].toString() получим ""
// "" приведен к 0
// 0 === 0

Более сложные примеры

Вспомним терминологию.

Операнд - то к чему применяется оператор.

Бинарный оператор - оператор, который применяется к 2м операндам (1 + 3)

Унарный оператор - оператор, который применяется к одному операнду (2++)

console.log(true + false) // 1
// Бинарный оператор + вызывает численное преобразование для true и false
// 1 + 0 (вернет 1)

console.log(12 / "6") // 2
// Оператор деления вызывает численное преобразование
// 12 / 6 (вернет 2)

console.log("number" + 15 + 3) // "number153"
// Тут + выполняется слева направо
// "number" + 15 (вернет "number15")
// Поскольку один из операндов + это строка, то второе число будет преобразовано в строку
// "number15" + "3" (вернет "number153")

console.log(15 + 3 + "number") // "18number"
// 15 + 3 (вернет 18)
// 18 + "number" (вернет "18number")

console.log([1] > null) // true
// Оператор сравнения вызывает численное преобразование
// [1] будет преобразован в 1
// null будет преобразован в 0
// 1 > 0 (вернет true)

console.log("foo" + +"bar") // "fooNaN"
// Унарный оператор имеет более высокий приоритет, чем унарный оператор
// +"bar" выполнится первый
// Унарный плюс вызывает численное преобразование "bar" (вернет NaN)
// "foo" + NaN тут так же сработает конкатинация (вернет "fooNaN")

console.log("true" == true) // false
// Оператор сравнения вызывает численное преобразование
// Левый операнд "true" преобразуется в NaN
// Правый операнд true станет 1
// NaN === 1 (вернет false)

console.log("false" == false) // false
// Оператор сравнения вызывает численное преобразование
// Левый операнд "false" преобразуется в NaN
// Правый операнд true станет 0
// NaN === 0 (вернет false)

console.log(null == "") // false
// Оператор == обычно вызывает численное преобразование, но не в случае с null
// null == null и null == undefined возращает true, а все остальные случаи вернут false

console.log(!!"false" == !!"true") // true
// Оператор !! конвертирует строки "false" и "true" в булевые значения 
// Получаем true == true, т.к. "false" не пустая строка (вернет true)

console.log(["x"] == "x") // true
// Оператор == вызывает численное преобразование у массива
// Метод массива valueOf возвращает сам массив. Этот результат игнориуется, т.к. не является примитивом
// Далее вызывается метод массива toString, который конвертирует ["x"] в "x"
// "x" == "x" (вернет true)

console.log([] + null + 1) // "null1"
// Оператор + вызывает численное преобразование массива
// Метод массива valueOf возвращает сам массив. Этот результат игнориуется, т.к. не является примитивом
// Далее вызывается метод массива toString, который конвертирует [] в ""
// "" + null (вернет "null")
// "null" + 1 (вернет "null1")

console.log([1, 2, 3] == [1, 2, 3]) // false
// В данном примере преобразование не происходит, т.к. оба массива одного типа
// Оператор == сравнивает объекты по ссылке, а не по значению
// Данные массивы являются двумя разными экземплярами
// Поэтому [1, 2, 3] == [1, 2, 3] вернет false

Пользуясь возможность возможностью хотелось бы рассказать о youtube канале Open JS на котором выкладываются обучающие ролики по JavaScript. Ни какой воды, рекламы и пустых рассуждений. Канал только начал свое развитие. Буду рад поддержке!

Спасибо за внимание!

Комментарии (15)


  1. ermouth
    04.01.2023 00:52
    +4

    Не пустой массив Number([1, 2, 3]) приводится к NaN

    Не так. Сначала на массиве выполняется .toString(), а потом что получилось кастится в Number. То-есть Number([1]) -> 1, Number(['']) -> 0.

    Если перед вашим примером переопределить Array.prototype.toString = function(){return this.join('')}, то Number([1,2,3]) вернёт 123, а не NaN.

    typeof null возвращает “object”. Этот баг существует уже много лет 

    Это дичь и кривота, но не баг, а типа так задумано. Задумка была что null это «не объект», нет объекта. То-есть его тип – объект, как у NaN тип – число.


  1. rock
    04.01.2023 01:58
    +5

    При этом в JavaScript 7 значений typeof

    typeof 1n // => 'bigint'
    

    Метод .toPrimitive вызывает valueOf у значения.

    Абстрактная операция ToPrimitive вызывает метод @@toPrimitive, и только при его отсутствии - фаллбэк на .valueOf и .toString.

    1 + { [Symbol.toPrimitive]() { return 1 }, valueOf() { return 2 }, toString() { return 3 }} // => 2
    

    Строгое сравнение и сравнение с приведением типов

    Забыли, например, про такой алгоритм сравнения, как SameValue - Object.is:

    Object.is(NaN, NaN) // => true
    Object.is(0, -0) // => false
    

    Мы готовы рассмотреть интересные примеры, которые встречаются на собеседованиях

    Интересный и далеко не всем известный пример для собеседования (не на джуна) это, например, document.all:

    typeof document.all // => 'undefined'
    document.all === undefined // => false
    document.all === null // => false
    document.all == undefined // => true
    document.all == null // => true
    !document.all // => true
    document.all instanceof HTMLAllCollection // => true
    


    1. iliazeus
      04.01.2023 09:37

      Не знаеье, случайно, почему именно у document.all такое странное поведение? Выглядит уж очень магически, наверняка есть какая-то история.


      1. LEXA_JA
        04.01.2023 11:52
        +1

        Если коротко, то из-за обратной совместимости. ЕМНИП раньше это было специфичное для какого-то браузера (IE6?) API. При этом использовались конструкции вида

        if (document.all) {
          // old browser
        } else {
          // new browser
        }
        

        Потом это дело решили стандартизировать и что-бы не сломать код добавили такой костыль.


  1. vanxant
    04.01.2023 03:19
    -1

    Не хватает коротких кастов !!x, ~~x, +x


    1. iliazeus
      04.01.2023 09:34
      +3

      Вы имеете в виду, не хватает совета так не писать? Магические значки не слишком способствуют пониманию кода, особенно в командах, где JS - не единственный язык.


      1. vanxant
        04.01.2023 10:57
        +1

        Ну во-первых, это достаточно часто встречается в коде. Имо значительно чаще чем valueOf. Уж как минимум !!x вместо проверок на одновременно не-пустоту, не-null и не-undefined я вижу постоянно. Да и унарный плюс для приведения к числу тоже.

        Во-вторых, эта "магия", как правило, работает примерно во всех языках с синтаксисом "в стиле Си" - Java, PHP и т.д. (хотя в языках со строгой типизацией часто не имеет особого смысла).


        1. iliazeus
          04.01.2023 11:06
          +1

          Уж как минимум !!x вместо проверок на одновременно не-пустоту, не-null и не-undefined я вижу постоянно.

          Вы не фиксили баги, когда кто-то забыл, что !!x не пропускает пустую строку или 0? Я фиксил.

          Во-вторых, эта "магия", как правило, работает примерно во всех языках с синтаксисом "в стиле Си"

          Приведите пример, если не сложно. Я не помню больше языков, где пишут ~~x или !!x.


          1. Suvitruf
            04.01.2023 12:42

            не пропускает пустую строку или 0

            До сих пор постоянно такое на фронтенде ловлю. Особенно в обработке пользовательского ввода)


          1. vanxant
            04.01.2023 15:13
            +1

            !!x не пропускает пустую строку или 0? Я фиксил.

            ну так блин, фиксить надо кривые ручёнки. Если кодер не знает, что 0 это falsey-значение, то может его назад за учебники посадить? Учить, чем равны false и true, и что такое "пустая строка/массив". Про NaN молчу.

            В пыхе подобное тоже довольно часто встречаю. Как и унарный плюс для каста к числу.

            Ну ок, ~~x в js это больше подсказка компилятору. Явно не уровень тех, кто неосилил двойное отрицание :)

            PS. Ну в общем сами говорите, что в коде это встречается. Поэтому, имо, неплохо было бы упомянуть в статье.


            1. kosuha666
              05.01.2023 23:38

              Ну ок, ~~x в js это больше подсказка компилятору. Явно не уровень тех, кто неосилил двойное отрицание

              Я Не суперспец в js подскажите что вы имели ввиду, почему это подсказка компилятору? это же битовое отрицание, как это может быть связано с стадией компиляции если значение в рантайме считается? Или если js видит ~~ то на стадии компиляции происходит оптимизация на какую то логику которая отбрасывает дробную часть?


              1. vanxant
                06.01.2023 12:58

                Именно. Все числа в JS по умолчанию имеют тип double (ну или BigInt). Если вы собираетесь делать какие-то тяжелые вычисления в 32-битных целых, то x = ~~x это ваш способ сказать компилятору, что x имеет тип int.

                И да, x сначала приводится к числу с плавающей точкой, а затем отбрасывается дробная часть.


          1. kosuha666
            05.01.2023 20:43
            +1

            Хм, ну такие баги скорее вызваны тем что человек забыл что 0 или '' - это допустимое значение переменной. Мне кажется в этом случае не использовать !! - не решает проблему
            тот же человек может написать
            if (value) {...}
            и value будет либо пустой строкой либо 0. и будет тот же баг.

            Если я правильно понял суть проблемы


  1. rukhi7
    04.01.2023 15:59

    Таблица с зелеными квадратиками - самый лучший-компактный способ описания логики языка.

    А про

    Значения typeof

    или я что то не понимаю или там какая то лажа, сначала написано что

    1. ”undefined”

    как я понимаю значит typeof null равно (в смысле возвращает?) "undefined"

    а потом написано:

    typeof null возвращает "object"

    Чему верить или что значит первый ”undefined”?

    В общем можно сделать вывод что в основе языка js лежит таблица преобразований которую просто надо зазубрить, так исторически сложилось.


  1. orekh
    05.01.2023 11:26

    В начале статьи намешана в кучу иммутабельность и семантика передачи по значению или по ссылке.

    let a = 1; let b = a; b += 1; console.log(a, b) // 1, 2 by value
    let a = []; let b = a; b[0] = 1; console.log(a, b) // [1], [1] by reference
    let a = [0]; a[0] = 1; console.log(a) // [1] mutable
    let b = "0"; b[0] = 1; console.log(b) // "0" immutable