📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[TypeScript] 타입스크립트 객체 타입 호환성의 이해 (구조적 타이핑)

객체 간 타입 호환성 (feat. 슈퍼 타입과 서브 타입)

type Type1 = {
    name: string;
    color: string;
    phone: number;
}

type Type2 = {
    name: string;
    color: string;
}

let user1: Type1 = {
    name: 'Kim',
    color: 'blue',
    phone: 1234
}

let user2: Type2 = {
    name: 'Lee',
    color: 'red'
}

user2 = user1; // 정상
user1 = user2; // Error! 여기서 에러가 나는 이유는?

타입스크립트의 슈퍼 타입과 서브 타입을 공부하면서 객체끼리 서로 할당이 될 때, 어떤 객체가 슈퍼 타입인지에 대해 공부한 적이 있었다. 두 개의 객체 타입이 있을 때, 서로 공통된 프로퍼티만 가지고 있는 객체가 슈퍼 타입이 되고 그 외에 추가로 가지고 있는 프로퍼티가 있는 객체가 서브 타입이 되었다. 위 코드에서는 Type2가 슈퍼 타입이 되고 Type1이 서브 타입이 된다. 따라서, 슈퍼 타입인 user2에는 서브 타입인 user1이 할당될 수 있지만, 서브 타입인 user1에는 슈퍼 타입인 user1이 할당될 수 없는 것이다.

 

이렇게 주입식으로 슈퍼 타입과 서브 타입을 외우고, "그냥 타입스크립트는 그런 거야!"라며 넘어갔던 부분이 지금 와서 다시 공부해 보니까 왜 그런 건지에 대한 의구심이 들기 시작했다. 슈퍼 타입과 서브 타입으로 객체끼리의 타입 간 호환성을 배웠지만, 만약 이런 개념을 배우지 못했다면 어떻게 이해했을까라는 생각이 들었다. 그래서 슈퍼 타입이고 자시고 간에 직관적이고 단순하게 객체 타입 간 호환성을 다시 이해해보고자 했다. 따라서, 이 글에서는 슈퍼 타입과 서브 타입이라는 용어를 사용하지 않고 객체 타입끼리의 호환성을 설명해보고자 한다.

 

타입스크립트의 구조적 타이핑

객체 타입 간 호환성을 설명하기 위해서는 타입스크립트의 특징 중 하나인 구조적 타이핑이라는 개념을 이해할 필요가 있다. 구조적 타이핑이란, 객체의 형태(구조)에 따라 타입 호환성을 결정하는 것을 말한다. 명시적으로 타입을 상속받지 않더라도 객체가 특정한 형태를 갖는다면 호환될 수 있음을 말하는데, 쉽게 말하자면 굳이 객체에 타입을 명시적으로 선언하지 않아도 대충 구조만 일치하면 타입 호환성이 보장된다는 뜻이다.

type player = {
    name: 'string';
    position: 'string';
    backNumber: 'number';
};

let user1: player = {
    name: 'Kim',
    position: 'attack',
    backNumber: 7,
};

let user2 = {
    name: 'Lee',
    position: 'defense',
    backNumber: 10,
    career: 5,
};

user1 = user2; // 정상 (user1 타입인 player에는 career가 없는데 들어가진다. 왜?)
user2 = user1; // Error!

위 코드를 보고 "user1에는 player라는 타입이 지정되어 있고, player 타입에는 user2의 career 타입이 없는데 user1 자리에 user2가 어떻게 할당된 거지?"라는 생각을 했다면, 아직 타입스크립트의 구조적 타이핑 개념을 모르고 있는 것이다. 또한, "그냥 user1이 슈퍼 타입이라서 그런 거 아니야?"라는 생각을 해도 마찬가지이다. (주입식은 이제 그만!)

 

타입스크립트는 2가지 얼굴을 가지고 있다. 첫 번째로 우리가 변수에 타입을 지정할 때, 타입스크립트는 "너 타입에 맞게 변수 선언했어?"라며 깐깐하게 우리를 째려본다. 마치 엄마처럼 "너 가방 챙겼어? 너 지갑 챙겼어?" 라며 제대로 타입을 지정했는지 검사하는 이 모습은 우리가 생각하는 일반적인 타입스크립트 컴파일러의 모습이다.

 

하지만, 타입스크립트는 한 가지의 얼굴을 더 가지고 있다. 이미 타입이 지정된 변수(혹은 타입이 지정된 함수의 파라미터)에 새로운 변수(파라미터)를 할당할 때는 우리가 알고 있는 깐깐한 얼굴이 아닌 너그러운 얼굴로 변한다. 그리고 "이 자리는 타입이 지정된 자리이기는 한데, 내가 봐서 대충 최소 요건만 맞으면 눈 감아 줄게"라고 말한다.

 

위 코드로 돌아가서 설명을 덧붙이자면, user1의 최소 요건은 player 타입의 name, position, backNumber가 된다. 즉, user1에 할당되기 위해서는 name, position, backNumber 프로퍼티만 가지고 있으면 되는 것이다. user2에 user1이 할당되지 못하는 이유도 user1이 user2의 최소 요건을 지키지 못했기 때문이다. (user1에는 career 프로퍼티가 없기 때문)

type colorType = {
    name: string;
    category: string;
};

const blue = {
    name: 'Blue',
    category: 'B01',
    product: 'sky',
};

function returnColor(color: colorType) {
    return color;
}

returnColor(blue); // 정상

객체뿐만 아니라 함수에서도 마찬가지이다. returnColor 함수의 파라미터의 타입(colorType)에는 name과 category 타입만 선언되어 있고, 파라미터 값으로 들어온 blue 변수에는 product라는 프로퍼티가 추가적으로 더 들어가 있다. 그럼에도 불구하고 함수는 정상적으로 작동하며, 이것 역시 변수 blue가 colorType 타입 변수의 최소 요건(name, category)을 지켰기 때문이다. 

 

우리가 그동안 객체 타입 호환성을 헷갈린 이유는 어쩌면 통념적으로 두 물체 간 포함 관계를 비교했을 때, 당연히 더 많은 쪽이 더 적은 쪽을 포함하고 있을 것이라고 생각했기 때문이다. 나 또한 객체에서도 당연히 프로퍼티가 더 많은 객체가 적은 객체를 포함하고 있다고 생각했었다. 하지만, 타입스크립트는 정반대였고 이것은 타입스크립트의 구조적 타이핑을 생각하면 쉽게 이해할 수 있는 문제이다.

 

1. 타입이 정해져 있는 변수 또는 파라미터에 재할당을 하게 될 경우 구조적 타이핑 개념이 작동한다.
2. 구조적 타이핑이란, 할당되는 값이 명시적으로 타입을 상속받지 않아도 할당받는 데이터가 특정한 형태를 갖는다면 호환될 수 있음을 의미한다.
3. 객체 간 타입 호환성에서는 '최소 요건'만 지키면 된다. 여기서 최소 요건이란 할당받는 객체에 지정된 타입이 가지고 있는 프로퍼티 타입이다.