타입스크립트: 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까지
준별
준별
  • 준별
    준별개발
    준별
  • 전체
    오늘
    어제
    • 분류 전체보기 (59)
      • 개발이야기 (15)
        • 토막글 (11)
      • 일상이야기 (6)
      • 개인 공부 (23)
      • 생각과 기록 (2)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바