본문으로 건너뛰기

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

1ilsang
1ilsang
클라이밍 하실래염?
#javascript#frontend
Published
·6 min read
{} == []  // 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
 

TL;DR!

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

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

들어가기 전에

이미지 주소

자바스크립트는 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-암묵적-타입-변환

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

동치연산자 한짤로 보기

출처: MDN

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

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

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

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

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍