타입스크립트를 사용하다보면 흔히 볼 수 있는 에러가 있습니다.
Type A is not assignable to type B. (2322)
위 케이스는 이해하기 너무나도 직관적이지만, 종종 저 타입 정의가 복잡해지게 되면 에러 해결에 어려움을 겪곤 합니다. 이 글에서는 이 에러 해결에 도움을 줄 수 있는 타입스크립트의 이론적인 개념들을 훑어봅니다.
서브타입과 슈퍼타입
Dog는 Animal인가요? 네 맞습니다.
Animal이 Dog인가요? 아닙니다.
그러므로, Dog는 Animal의 부분집합입니다.
타입 세상에서는 더 큰 집합인 Animal을 슈퍼타입, 그 부분집합인 Dog를 서브타입이라 부릅니다.
Animal을 담는 변수에 Dog를 담을수는 있지만, Dog를 담는 변수에 Animal을 담을 수는 없습니다.
Dog는 Animal에 호환되고, 이것을 업 캐스팅이라 합니다.
Animal은 Dog에 호환되지 않습니다. 다운캐스팅은 일반적으로 불가능합니다.
타입 계층 트리
타입스크립트가 제공하는 타입들간의 호환 관계는 아래와 같습니다.
Unknown은 최상위의 타입입니다. 비유하자면 모든 Animal은 물론, Plant, Bacteria 등 살아있는 모든 것을 받아들일 수 있는 Organism 같은 가장 큰 범주라고 할 수 있습니다. 모든 타입의 아버지이자, 전체집합입니다.
반면, never는 최하위 집합입니다. Never 타입으로 정의된 변수에는 그 무엇도 담길 수 없습니다. 심지어 null이나 undefined 같은 비어있는 값조차도 안됩니다. Never는 공집합 입니다.
Any는 지금까지 설명한 내용과는 다른 예외적인 타입입니다. 위 그림만 보면 그저 unknown의 하위타입으로 보입니다. 그러나 Any는 never를 제외한 모든 변수에 할당될 수 있고, any로 정의된 변수는 모든 값을 받아들일 수 있습니다. 즉, 다운캐스팅이 가능합니다. Any 변수에는 Dog가 담길수 있고, Dog 변수에도 Any를 담을 수 있습니다.
let unknownVar: unknown;
let neverVar: never;
let anyVar: any = 1;
let booleanVar: boolean = true;
neverVar = anyVar; // Error!!
unknownVar = anyVar;
booleanVar = anyVar; //이게 되다니, any의 사용을 주의해야 하는 이유를 알 것 같다.
booleanVar = unknownVar; // Error!!
anyVar = unknownVar;
anyVar = neverVar;
anyVar = booleanVar;
Type Assertion
Typescript에게 타입 정보를 넣어주는 방법으로 as 키워드를 사용하곤 합니다. 그러나 이 as 는 assertion 전 후의 두 타입이 서로 캐스팅 가능해야만 동작합니다.
Number와 string은 아무 관계도 아니므로 number를 string으로 assertion할 수 없습니다.
Dog와 Animal은 서로 assertion이 가능하지만, Dog와 Plant, Animal과 Plant는 assertion이 불가능합니다.
쉽게 말하면, 위 타입 계층 트리 이미지 상에서 부모관계끼리는 assertion 가능하나, 형제 관계에서는 assertion이 불가능한 것입니다.
let num: number = 1;
num as string; // Error!
num as unknown; // Unknown은 모든 타입의 아버지
num as never; // never는 모든 타입의 아들래미
num as any; // any는 어디에나 사용가능한 돌연변이
undefined as null; // Error!
const a: number = '1' as any as number; // 타입오류는 없으나, 당연히 런타임 오류를 유발할 수 있으므로, 절대 지양해야함
Nominal Typing 과 구조적 타이핑
type Animal = {
age: number;
}
//1. Nominal Typing
type Dog = Animal & {
bark: () => void;
}
//2. Structural Typing
type Dog = {
age: number;
bark: () => void;
}
좀 더 복잡한 호환성을 따져봅시다. Animal은 age라는 프로퍼티를 갖습니다. 우리는 Dog라는 타입을 추가하고 싶고, Dog는 bark같은 Dog만의 프로퍼티를 추가하고 싶습니다. 동시에, Animal과의 호환성도 놓치고 싶지 않습니다. 관련하여, 프로그래밍 언어론에서 사용하는 두 가지 서브타이핑 개념을 소개합니다.
1 번 방법처럼 직접 Animal 타입과 명시적으로 관계가 있음을 밝혔기에 호환을 허용하는 것을을 Nominal subtyping(명목적 서브타이핑) 이라 합니다.
반면, 2 번 방법처럼 Animal 타입과 같은 age 프로퍼티를 추가함으로써, 구조적인 공통점 만으로도 호환을 허용하는 것을 Structural subtyping(구조적 서브타이핑)이라 합니다. 즉, Nominal subtyping이 더 엄격한 조건입니다.
Typescript는 두가지 서브타이핑을 모두 허용합니다!
//1. Nominal Typing
type DogA = Animal & {
bark: () => void;
}
const dogA: DogA = {
age: 1,
bark: () => console.log("멍!"),
}
const animalA: Animal = dogA; // 문제 없음!
//2. Structural Typing
type DogB = {
age: number;
bark: () => void;
}
const dogB: DogB = {
age: 2,
bark: () => console.log("왈!"),
}
const animalB: Animal = dogB; // 문제 없음!
그러나, 구조적 서브타이핑이 예외적으로 동작하지 않는 경우가 있습니다. 어떤 객체가 Fresh한 객체라면, 구조적 서브타이핑은 허용되지 않습니다.
// 예제 1
function howOldAnimalIs(animal: Animal) {
return animal.age;
}
howOldAnimalIs(dogA); // 가능
howOldAnimalIs(dogB); // 가능
howOldAnimalIs({ age: 1 }); // 가능
howOldAnimalIs({ age: 1, bark: () => console.log("깽") }); // Error!
// Argument of type '{ age: number; bark: () => void; }' is not assignable to parameter of type 'Animal'.
// Object literal may only specify known properties, and 'bark' does not exist in type 'Animal'.(2345)
//예제 2
const animal: Animal = {
age: 1,
bark: () => console.log("멍!"),
} // Error!
// Type '{ age: number; bark: () => void; }' is not assignable to type 'Animal'.
// Object literal may only specify known properties, and 'bark' does not exist in type 'Animal'.(2322)
두 에러 모두 구조적 서브타이핑이 허용되었다면, Animal의 서브타입으로써 에러가 나지 않았을 것입니다. 그러나, 위 두 예제에서는 객체를 만들자마자(!) Animal의 서브타입으로 캐스팅하려 하여 실패한 것입니다. 이렇게 갓 만든 따끈따끈한 객체를 우리는 Fresh 하다고 하며, Fresh한 객체에 대해서는 구조적 서브타이핑을 허용하지 않는 것입니다. 이 객체의 Freshness에 대해서는 이 링크를 참고해주세요.
이해를 돕기 위해 추가코드를 적자면, 아래의 코드들은 모두 정상 동작 합니다.
const dogC = { age: 1, bark: () => console.log("깽") }; // 이제 Fresh하지 않다.
howOldAnimalIs(dogC);
const dogD = {
age: 1,
bark: () => console.log("멍!"),
}; // 이제 Fresh하지 않다.
const animalD: Animal = dogD;
변성
Dog와 Animal의 호환성은 알았는데, Dog[] 와 Animal[]의 호환성은 어떨까요? 혹은 Record<Dog, string>과 Record<Animal, string>은 어떨까요? (dog: Dog) ⇒ void와 (animal: Animal) ⇒ void는 어떨까요? 이 케이스들을 어떻게 허용할지에 대한 규칙을 프로그래밍 언어론에서는 변성(Variance)이라고 부릅니다.
공변성(Covariance)
A ≤ B 일 때, T<A> ≤ T<B> 이다. 라는 성질입니다.
대부분의 상황에서 만족합니다.
Dog ≤ Animal 이므로, Dog[] ≤ Animal[]입니다.
예시2) Dog ≤ Animal 이므로, Record<Dog, string> ≤ Record<Animal, string> 입니다.
예시3) string ≤ string | number 이므로, () ⇒ string ≤ () ⇒ string | number 입니다.
반공변성(Contravariance)
A ≤ B 일 때, T<A> ≥ T<B> 이다. 라는 성질입니다. (공변성과 방향이 반대)
함수의 인수로 사용될 때 만족합니다.
Dog ≤ Animal 이므로, (x: Dog) ⇒ void ≥ (x: Animal) ⇒ void 입니다.
string ≤ string | number 이므로, (x: string) ⇒ void ≥ (x: string | number) ⇒ void 입니다.
정리하면, 타입스크립트에서는 함수의 인수에 대해서는 반공변성을 갖고, 나머지 케이스에서는 모두 공변성을 갖습니다. 왜 그렇게 되어 있을까요? 직관적으로 이해할 수 있도록 아래의 예시를 적어두었습니다.
string | number 타입의 변수에 string을 넣는 것은 문제가 되지 않으나, string 타입의 변수에 string | number를 넣는 것은 뭔가 이상하다. String 타입의 변수에 number가 들어갈 수 있다는 뜻이 되기 때문이다.
(x: string) ⇒ void 타입의 변수에 (x: string | number) ⇒ void를 넣는 것은 문제가 되지 않으나, (x: string | number) ⇒ void에 (x: string) ⇒ void 타입을 넣는 것은 뭔가 이상하다. x에 number 타입의 값을 넣어도 동작해야하는데, 실제로 동작하지 않게 될 것이기 때문이다.
그런데.. 사실 이 반공변성에 대한 이야기도 예외가 있습니다. Typescript compiler 설정 항목 중 —strictFunctionTypes 모드는 True로 설정되어 있습니다. 이 항목의 값을 False로 변경하면 함수의 인자는 반공변성이 아닌 이변성(Bivariance)을 갖게 됩니다!
이변성(Bivariance)
A ≤ B 일 때, T<A> ≤ T<B> 와 T<A> ≥ T<B> 모두 가능하다. 라는 성질입니다.
string ≤ string | number 이므로, (x: string) ⇒ void ≥ (x: string | number) ⇒ void 일수도 있고, (x: string) ⇒ void ≤ (x: string | number) ⇒ void 일수도 있다.
뭔가 이상하게 느껴질 수 있습니다. string과 number를 모두 처리할 수 있을 것으로 보이는 함수의 자리를 string만 처리할 있는 함수로 채워넣으면 런타임 에러가 날 것이 분명하다. 그럼에도 불구하고, typescript는 왜 이런 옵션을 제공하는걸까요? 이 비하인드 스토리는 이 링크를 참고해주세요
참고한 글
https://itchallenger.tistory.com/447
https://www.typescriptlang.org/ko/docs/handbook/2/everyday-types.html
https://toss.tech/article/typescript-type-compatibility
'개발이야기' 카테고리의 다른 글
Cursor AI 사용 후기 - 나 개발자 밥그릇 뺏기겠는데? (0) | 2025.01.12 |
---|---|
Node.js에서 csv 파일 다루기 및 ios-윈도우 간 한글 깨짐 문제 해결 (1) | 2024.01.06 |
한글의 유니코드 인코딩과, javascript에서 한글 문자열을 다루는 방식 (1) | 2023.10.19 |
HTTP의 역사: 0.9부터 3.0까지 (1) | 2023.09.14 |
데코레이터(Decorator) (1) | 2023.09.05 |