타입스크립트: ts2322 error 해결을 위한 서브타입 관련 개념 총정리

2023. 11. 26. 19:07·개발이야기

타입스크립트를 사용하다보면 흔히 볼 수 있는 에러가 있습니다.

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에 호환되지 않습니다. 다운캐스팅은 일반적으로 불가능합니다.

 

 

타입 계층 트리

타입스크립트가 제공하는 타입들간의 호환 관계는 아래와 같습니다.

https://velog.io/@jeris/TypeScript-타입스크립트의-타입

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://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EA%B3%B5%EB%B3%80%EC%84%B1-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-%F0%9F%92%A1-%ED%95%B5%EC%8B%AC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

https://itchallenger.tistory.com/447

https://www.typescriptlang.org/ko/docs/handbook/2/everyday-types.html

https://toss.tech/article/typescript-type-compatibility

https://driip.me/d875a384-3fb9-471b-a53b-b3ca52f8238e

https://driip.me/d230be64-df1d-4e9a-a8c2-cba6bbc0ae15

반응형
저작자표시 (새창열림)

'개발이야기' 카테고리의 다른 글

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
'개발이야기' 카테고리의 다른 글
  • Cursor AI 사용 후기 - 위기의 내 밥그릇
  • Node.js에서 csv 파일 다루기 및 ios-윈도우 간 한글 깨짐 문제 해결
  • 한글의 유니코드 인코딩과, javascript에서 한글 문자열을 다루는 방식
  • HTTP의 역사: 0.9부터 3.0까지
준별
준별
  • 준별
    준별개발
    준별
  • 전체
    오늘
    어제
    • 분류 전체보기 (44)
      • 개발이야기 (12)
        • 토막글 (9)
      • 일상이야기 (5)
      • 개인 공부 (16)
      • 생각과 기록 (2)
  • 블로그 메뉴

    • 홈
    • 방명록
    • Github
    • Linkedin
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    바이브코딩
    정보보호개론
    전산기조직
    http3.0
    http1.1
    nestjs
    클램쉘
    데이터베이스
    맥북터미널세팅
    artillery
    필수툴
    zsh-autosuggestion
    실전압축
    persistent connection
    k9s
    Zsh
    zsh세팅
    맥북
    터미널꾸미기
    http1.0
    nodejs
    조합형
    터미널세팅
    http pipelining
    http2.0
    powerlevel10k
    맥북세팅
    맥북초기세팅
    데스크셋업
    이산구조
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
준별
타입스크립트: ts2322 error 해결을 위한 서브타입 관련 개념 총정리
상단으로

티스토리툴바