알랑말랑 암묵적 형변환 말랑말랑 이해하기

1ilsang
1ilsang
클라이밍 하실래염?
published
{} == []  // ERROR
[] == {}  // false
[] == ''  // true
[] == []  // false
"[object Object]" == {}  // true
[45] == 45  // true
[45] == '45'  // true
4 * []  // 0
[] + {}  // "[object Object]"
{} + []  // 0
0 == '\n'  // true
1 + 2 + '3'  // 33
NaN == NaN  // false
undefined == null // true

cover

TL;DR!

  1. 위 예시의 결과값이 도출되는 과정을 이해한다.
  2. 암묵적 형변환을 유도하지 말라
  3. 암묵적 형변환을 유도하지 마시오

타입스크립트를 사용하면 되지 않나요?

들어가기 전에

primitive-type

이미지 주소

자바스크립트는 6가지 원시 타입과 Object 라는 객체 타입, 총 7가지 타입 이 존재한다.

ES2020에서 원시 타입에 bigint 타입이 추가되었기 때문에 이제는 총 8가지 타입이 존재하게 되었다.

기본적으로 암묵적 형변환은 모두 "원시 타입(문자열, 숫자, 불리언)"을 기준으로 하게 된다. 원시타입이 객체타입으로 암묵적 형변환이 되는 케이스는 존재하지 않는다.

암묵적 형변환은 언제 일어나나요?

// 표현식이 모두 문자열 타입이여야 하는 컨텍스트
const a = '10' + 2; // "102"
const b = `1 * 10 = ${1 * 10}`; // "1 * 10 = 10"

// 표현식이 모두 숫자 타입이여야 하는 컨텍스트
5 * '10'; // 50

// 표현식이 불리언 타입이여야 하는 컨텍스트
!0; // true
if (1) {
}
1 == []; // false

자바스크립트 엔진은 표현식을 평가할 때 문맥, 즉 컨텍스트(Context)에 고려하여 암묵적 타입 변환을 실행한다.

https://poiemaweb.com/js-type-coercion#2-암묵적-타입-변환

  • 산술 연산자(+-*/)의 경우 + 는 문자열이 우선순위가 더 높으며 나머지 연산은 숫자가 더 우선순위가 높다.
  • 동치 연산자(==)의 경우 피연산자간의 관계에 따라 정의가 다르다.

동치연산자 한짤로 보기

example

출처: MDN

동치 연산의 관계를 보면 Object 타입의 경우 ToPrimitive 라는 값이 있다.

이 함수가 암묵적 형변환의 핵심이며, 이 함수를 이해하면 타입 변환의 과정을 이해할 수 있다.

ToPrimitive 는 동치연산 뿐만 아니라 원시값과 비교가 필요한 모든 순간에 동작한다

to-primitive

Symbol.toPrimitive: A method that converts an object to a corresponding primitive value. Called by the ToPrimitive abstract operation.

ECMA2020

설명과 같이 객체의 원시 타입의 값을 반환하는 Symbol.toPrimitive 메서드는 ToPrimitive 추상 명령 에서 사용된다.

function toPrimitive(input, PreferredType) {
  // PreferredType은 호출자가 기대하는 타입
  if (typeof input === 'object' || typeof input === 'function') {
    let hint =
      PreferredType === undefined
        ? 'default'
        : typeof PreferredType === 'string'
          ? 'string'
          : 'number';
    let exoticToPrim = input[Symbol.toPrimitive];
    if (exoticToPrim !== undefined) {
      let result = exoticToPrim.apply(input, [hint]);
      if (!(typeof input === 'object' || typeof input === 'function'))
        return result;
      throw new TypeError();
    }
    if (hint === 'default') hint = 'number';
    return OrdinaryToPrimitive(input, hint);
  }
  return input;
}

코드 출처

input 이 객체이며 toPrimitive 추상 명령이 해당 객체 내에 없다면(input[Symbol.toPrimitive]) OrdinaryToPrimitive 를 호출하고 있다.

input[Symbol.toPrimitive] 이 메서드는 객체 프로퍼티로 개발자가 직접 넣은 케이스이므로 여기서는 넘어가겠다.

hint 는 어떤 원시 타입을 부를지에 대한 정의로써, 기본 타입이 넘버 타입인 것을 인지하고 넘어가자.

function OrdinaryToPrimitive (O, hint) {
  if ( typeof O === "object" || typeof O === "function" ) {
    if( typeof hint === "string" && ( hint === "string" || hint === "number" ) ) {
      let methodNames = hint === "string" ? [ "toString", "valueOf" ] : [ "valueOf", "toString" ];
      for( name of methodNames ) {
        let method = O[name];
        if( typeof method === "function" ) {
          let result = method.apply(O);
          if( typeof result !== "object" && typeof result !== "function" ) return result;
        }
    }
  }
 throw new TypeError();
}

코드 출처

hintstring 이면 [toString, valueOf] 이며 number 이면 [valueOf, toString] 순서로 우선권을 가지는 것을 볼 수 있다.

여기서 우선권 이라는 단어를 사용하였는데, 그 이유는 for 문을 통해 apply 하는 순서가 달라지기 때문이다.

원시 타입을 찾았다면(if( typeof result !== "object" && typeof result !== "function" )) 결과를 반환하고 아니면 무시된다 이는 굉장히 중요한데, 아래에서 예시로 다루겠다.

  • 따라서, 타입간 비교에서 암묵적 형변환들은 모두 원시타입으로 변환하기 위한 과정 속에서 일어난다.

예제로 정리하기

1. 4 * []  // 0
2. 4 + []  // "4"
3. [] + {}  // "[object Object]"
4. [45] == 45  // true
5. {} == []  // ERROR
6. 0 == '\n'  // true
// CASE 1.
1. 4 * []
// +를 제외한 산술 연산의 경우 숫자타입이 최상위 우선순위이므로 암묵적 형변환은 Number == ToPrimitive([]) 으로 될 것이다.
2. 4 * Object([])  // 4 * []
// Symbol.toPrimitive 정의를 해주지 않았으므로 default hint 는 number 로 설정된다.
3. 4 * Object([]).valueOf()  // 4 * []
// Default hint 가 number 이므로 [valueOf, toString] 순으로 원시 값을 가져올 것이다.
4. 4 * Object([]).valueOf().toString()  // 4 * ""
// 하지만 valueOf 는 this 반환으로 객체([])를 반환해 원시타입이 아니게 되므로 무시된다. 따라서 후순위의 toString 함수가 실행된다.
5. 4 * Number(Object([]).valueOf().toString()) // 4 * 0
// 숫자 * 문자열 연산에서 숫자가 우선순위가 높으므로 Number 타입으로 형변환이 된다.
6. 0
// 그 결과 4 * 0 이 되어 0이 최종 리턴된다.
// CASE 2.
1. 4 + []
2. 4 + Object([]).valueOf().toString() // ""
// CASE1 예시의 4번까지와 동일하다.
3. String(4) + Object([]).valueOf().toString() // "4"
// + 연산자에서는 숫자보다 문자가 우선순위를 가지므로 숫자가 String 으로 변환되었다.
4. "4"
// CASE 3.
1. [] + {}
2. Object([]) + Object({})
3. Object([]).valueOf() + Object({}).valueOf()  // [] + {}
// 모두 객체자신을 반환하므로 toString 연산까지 진행하게 된다.
4. Object([]).valueOf().toString() + Object({}).valueOf().toString()  // "" + "[object Object]"
// 객체의 toString 은 prototype 상속으로 최종 this 결과값을 반환해 object Object 가 나타난다.
// Object.prototype.toString.call(undefined) 호출시 "[object Undefined]" 가 나오는 것 처럼.
5. "[object Object]"
// CASE 4.
1. [45] == 45
2. Object([45]) == 45
3. Object([45]).valueOf() == 45 // [45] == 45
4. Object([45]).valueOf().toString() == 45 // "45" == 45
5. Number(Object([45]).valueOf().toString()) == 45 // 45 == 45
6. true
// CASE 5.
1. {} == []
// {} 중괄호는 "객체"로 인식되는 것이 아닌 "블록 스코프"로 인식되어 사라져버린다!
2. == []
3. // Uncaught SyntaxError: Unexpected token '=='
// CASE 6.
1. 0 == '\n'
2. 0 == ""
3. 0 == Number('')
4. 0 == 0
5. true

한발 더 나아가기

  1. hint 는 언제 default 값을 벗어나게 될까?
    • <, > 혹은 -, * 와 같이 명확한 숫자 비교에선 number 가 된다.(+ 는 문자열도 포함되므로 제외된다)
  2. Date 객체를 제외한 모든 내장 객체는 defaultnumber 를 동일하게 처리하므로 number 로 이해하는게 편하다.
  3. boolean 타입의 hint 는 존재하지 않는다. 모든 객체는 true 로 평가되므로 string, number 만 처리하면 된다.
  4. [Symbol.toPrimitive] 를 커스텀 할 수 있는가?
    • 가능하다.
const user = {
  name: '1ilsang',
  money: 1000,

  [Symbol.toPrimitive](hint) {
    alert(`hint: ${hint}`);
    return hint == 'string' ? `{name: "${this.name}"}` : this.money;
  },
};

// 데모:
alert(user); // hint: string -> {name: "1ilsang"}
alert([user] == '{name: "1ilsang"}'); // hint: string -> {name: "1ilsang"} == {name: "1ilsang"}; true;
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> default는 number가 기본타입이므로 1000 + 500 -> 1500
alert('3' - user); // hint: number -> '3' - 1000 -> 3 - 1000 -> -997
alert(user > 10); // hint: number -> 1000 > 10; true
alert(user + new Date()); // hint: default -> 1000 + 'Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)' -> '1000Sat Apr 22 2023 17:02:00 GMT+0900 (한국 표준시)'
  1. NaN 은 모든 경우에서 같지 않다.
    • NaN == NaN // false

결론

  1. 타입간 비교에서 암묵적 형변환들은 모두 원시 타입으로 변환하기 위한 과정 속에서 ToPrimitive 추상 명령을 통해 일어난다.
  2. == 연산자와 === 연산자의 차이는 무엇인가?
    • "타입까지 비교 여부" 라고하면 애매하다. "암묵적 형변환을 허용하는가"의 차이가 더 명확한 워딩이다.
  3. 타입스크립트와 === 연산자를 사용하자.

Ref