7 분 소요

타입은 집합이다

타입스크립트의 ‘타입’은 사실 여러개의 값들을 포함하는 ‘집합’이다. 집합은 동일한 속성을 갖는 여러개의 요소들을 하나의 그룹으로 묶은 단위를 말한다. 따라서, 다음 그림처럼 여러개의 숫자 값들을 묶어 놓은 집합을 타입스크립트에서는 number 타입이라고 부른다.

마찬가지로 오직 하나의 값만 포함하는 number literal 타입 또한 딱 하나의 값만 포함하는 아주 작은 집합이다.

그리고 이 20이라는 number literal에 속하는 요소인 숫자 20은 20 number literal 이외에 number라는 거대한 집합에도 속한다. 그러므로 결국 모든 number literal 타입은 number 타입이라는 거대한 집합에 포함되는 부분 집합이라고 볼 수 있다.

이렇게 타입스크립트의 모든 타입들은 집합으로써 서로 포함하고 또 포함되는 이런 관계를 갖는다. 그리고 이러한 관계에서 number 타입처럼 다른 타입을 포함하는 타입슈퍼 타입(부모 타입)이라고 부른다. 반대로 다른 타입에 포함되는 타입을 해당 부모 타입의 서브 타입(자식 타입)이라고 부른다.

이 관계를 계층 처럼 표시하면 다음과 같은 그림이 된다.

이러한 방식으로 타입스크립트에서 제공하는 여러 기본 타입들 간의 집합으로써의 부모-자식 관계를 표현하면 아래의 타입 계층도가 완성된다.


타입 호환성

타입 호환성이란, 예를 들어 A와 B 두 개의 타입이 존재할 때 A타입을 B타입으로 취급해도 괜찮은 지 판단하는 것을 의미한다. 만약 A타입이 B타입의 값으로 취급되어도 괜찮다면 호환된다고 하고, 안된다면 호환되지 않는다고 한다.

예를 들어, 다음 그림처럼 number 타입과 number literal 타입이 있을 때 서브 타입인 number literal 타입의 값을 슈퍼 타입인 number 타입의 값으로 취급하는 것은 전혀 문제되지 않는다. 하지만 number 타입을 number literal 타입으로 취급할 경우에는 문제가 될 여지가 있다. 따라서 number literal 타입은 number 타입에 호환되고, number 타입은 number literal 타입에 호환되지 않는다.

이를 코드로 설명하면, 다음과 같이 number 타입의 변수에 number literal 타입의 변수가 들어가는 건 괜찮지만 그 반대는 안된다고 볼 수 있다.

let num: number = 10;
let numLiteral: 10 = 10;

num = numLiteral; // OK
numLiteral = num; // Error

따라서 타입스크립트에서는 이렇게 슈퍼 타입의 값을 서브 타입의 값으로 취급하는 것을 허용하지 않는다. 다시 말해, 슈퍼 타입의 값을 서브 타입의 값에 담는 것을 허용하지 않는다.

그리고 특별히 서브 타입의 값을 슈퍼 타입의 값으로 취급하는 것‘업 캐스팅’, 슈퍼 타입의 값을 서브 타입의 값으로 취급하는 것‘다운 캐스팅’라고 부른다. 업 캐스팅은 모든 상황에 호환 가능하지만 다운 캐스팅은 대부분의 상황에 호환 불가능하다고 할 수 있다.

타입 계층도에서 아래의 타입의 변수가 위의 타입의 변수에 들어가는 것이 업 캐스팅이라고 이해하면 쉽게 기억할 수 있다 !


타입 계층도의 호환성

이제 앞서 배운 호환성의 개념을 통해 타입 계층도의 각 타입을 살펴보자.

unknown 타입 (전체 집합)

unknown타입은 타입 계층도의 최상단에 위치한다. 따라서 unknown 타입의 변수에는 모든 타입의 값을 할당할 수 있다. 즉, 모든 타입은 unknown 타입으로 업 캐스트 할 수 있다.

let a: unknown = 1;
let b: unknown = "hello";
let c: unknown = true;
let d: unknown = null;
let e: unknown = undefined;
let f: unknown = [];
let g: unknown = {};
let h: unknown = () => {};

unknown 타입이 타입 계층도에서 가장 위에 위치한다는 뜻은 모든 타입의 슈퍼 타입이라는 뜻이다. 그러므로 모든 타입은 unknown 타입의 부분집합이다.

unknown타입의 다운 캐스트는 예외 타입인 `any`를 제외한 어떤 타입의 변수에도 불가능 하다.

never 타입 (공집합)

never 타입은 타입 계층도에서 가장 아래에 위치한다.

never 타입은 공집합 타입으로, 아무것도 포함하지 않는 집합이다. 따라서 never 타입에 해당하는 값은 아무것도 없다. 따라서 다음과 같이 아무것도 반환할 수 없는 상황에 never 타입을 사용한다.

또한 수학에서 공집합은 모든 집합의 부분집합임을 배웠듯이, never 타입은 모든 타입의 서브 타입이다. 따라서 never 타입은 모든 타입으로 업캐스팅할 수 있다.

let neverVar: never;

let a: number = neverVar;
let b: string = neverVar;
let c: boolean = neverVar;
let d: null = neverVar;
let e: undefined = neverVar;
let f: [] = neverVar;
let g: {} = neverVar;

반면 그 어떠한 타입도 never 타입으로 다운캐스팅할 수 없다.

let a: never = 1;
let b: never = "hello";
let c: never = true;
let d: never = null;
let e: never = undefined;
let f: never = [];
let g: never = {};

void 타입

void 타입은 앞서 기본 타입을 배울 때 아무것도 반환하지 않는 함수의 반환값 타입으로 주로 사용된다고 설명했었다. 아래 타입 계층도에서 void 타입을 살펴보면 undefined 타입의 슈퍼타입임을 알 수 있다.

따라서 반환값을 void로 선언한 함수에서 undefined를 반환한다고 하더라도 오류가 발생하지 않는다. void안에 undefined가 속해있기 때문, 즉 undefined가 void의 서브타입이기 때문, undefined가 void에 업캐스팅이 가능하기 때문이다. 다 같은 말이다 !!

function noReturnFuncA(): void {
  return undefined;
}

function noReturnFuncB(): void {
  return;
}

function noReturnFuncC(): void {}

let voidVar: void;
voidVar = undefined;

let neverVar: never;
voidVar = neverVar;

any 타입 (치트키)

자자, 대망의 any 타입이다. 자바스크립트를 타입스크립트로 마이그레이트할 때 가장 자주 사용할 타입이면서, 우아한 타입스크립트를 위해선 가장 사용하지 말아야 하는 타입이다.

any 타입은 예외 그 자체이다. 모든 타입의 슈퍼타입이 될 수도 있고, 서브타입이 될 수도 있다.

let anyValue: any;

let num: number = anyValue;
let str: string = anyValue;
let bool: boolean = anyValue;

anyValue = num;
anyValue = str;
anyValue = bool;

객체 타입

앞서 기본 타입들의 호환성을 알아보았는데, 객체 타입 간의 호환성도 동일하게 판단할 수 있다.

모든 객체 타입은 각각 다른 객체 타입들과 슈퍼-서브타입의 관계를 갖는다. 따라서 기본 타입들과 마찬가지로 업 캐스팅은 허용하되, 다운 캐스팅은 허용하지 않는다. 다음 예시를 통해 자세히 알아보자.

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

type Dog = {
  name: string;
  color: string;
  breed: string;
};

let animal: Animal = {
  name: "기린",
  color: "yellow",
};

let dog: Dog = {
  name: "돌돌이",
  color: "brown",
  breed: "진도",
};

animal = dog; // ✅ OK
dog = animal; // ❌ NO

사실 언뜻 보기에 Dog 안에 Animal이 있다고 생각하기 쉽다. Dog 타입에는 3개 프로퍼티가 있는데 Animal 타입은 Dog 타입에 속한 프로퍼티 2개가 있으니 뭔가 포근하게 사악 Dog에 들어갈 수 있을 것 같다는 느낌? 따라서 Dog이 Animal의 슈퍼타입일 것 같은 느낌? 들 수 있다.

하지만 이제는 제대로 알자. Animal 타입 객체를 Dog 타입 객체에 넣는 것은 불가능하다. Animal이 Dog의 슈퍼 타입이다. 개념만 살짝 이해하고 가자 !

타입스크립트는 프로퍼티를 기준으로 타입을 정의하는 “구조적 타입 시스템”을 따른다고 배웠다. 따라서 Animal 타입은 `name`과 `color` 프로퍼티를 갖는 모든 객체들을 포함하는 집합으로 볼 수 있고, Dog 타입은 namecolor에 더해 breed 프로퍼티를 추가로 갖는 모든 객체를 포함하는 집합으로 볼 수 있다. 즉, 있는 프로퍼티는 전부 다 가져야 (선택적 프로퍼티가 아닌 한) 그 타입 집합이라고 할 수 있는 것이다. 따라서 어떤 객체가 Dog 타입에 포함된다면 name, color, breed 프로퍼티가 존재하는 것이고 그렇다면 이 객체는 무조건 Animal 타입에도 포함이 된다. 하지만 어떤 객체가 Animal 타입에 포함된다면 그 객체는 breed 프로퍼티가 있을 수도 없을 수도 있기 때문에 Dog 타입에 포함된다고 확신할 수 없다. 따라서 Animal이 Dog의 슈퍼타입인 것이다.

잘 모르겠다면, 이렇게 이해하자. 프로퍼티가 많을 수록, 조건이 많아지므로 속하는 객체가 적어진다. 반면 프로퍼티가 적을 수록 여러 객체가 포괄적으로 광범위하게 속할 수 있다. 여기에 특정 A 타입의 모든 프로퍼티를 가진 더 많은 프로퍼티의 B 타입이 있다? 얘는 A의 서브타입이다.

초과 프로퍼티 검사

ㅇㅋ 이제 어떤 객체가 슈퍼-서브타입인지도 알았고, 업캐스팅이 되는지도 알았다. 하지만 다음과 같이 서브타입의 객체 리터럴을 통해 업캐스팅하면서 초기화를 한다면 오류가 발생할 수 있다.

type Person = {
  name: string;
  age: number;
};

type Programmer = {
  name: string;
  age: number;
  skill: string;
};

let potato: Person = {
  // 오류 발생
  name: "감자",
  age: 1,
  skill: "TypeScript",
};

왜일까? {name: "감자", age: 1, skill: "TypeScript"}는 Programmer의 타입의 객체고 이는 Person 타입의 서브타입이니까, Person 타입의 potato 객체에 업캐스팅할 수 있는 것 아닌가?

결론부터 말하면 이는 ‘초과 프로퍼티 검사’가 발동되서 그렇다. 초과 프로퍼티 검사란 변수를 객체 리터럴로 초기화할 때 발동되는 타입스크립트의 특수한 기능으로, 타입에 정의된 프로퍼티 이외의 다른 초과된 프로퍼티를 갖는 객체를 변수에 할당할 수 없도록 막는다.

이런 초과 프로퍼티 검사는 단순히 변수를 초기화할 때 객체 리터럴을 사용하지만 않으면 발생하지 않는다. 따라서 다음과 같이 서브타입 객체를 별도의 다른 변수에 보관한 다음 초기화 값으로 사용하면 발생하지 않는다.

let programmerVar: Programmer = {
  name: "감자",
  age: 1,
  skill: "TypeScript",
};

let potato: Person = programmerVar;

함수의 매개변수에 인수로 값을 전달하는 과정도 변수를 초기화하는 과정과 동일하다. 따라서 초과 프로퍼티 검사를 피하기 위해선 다음과 같이 함수에 전해줄 값을 미리 변수에 담아둔 다음 변수값을 인수로 전달해야 한다.

function func(user: User) {}

func({
  // 오류 발생
  name: "감자",
  age: 1,
  skill: "TypeScript",
});

func(programmerVar); // OK

대수 타입

대수 타입이란 여러 개의 타입을 합성해서 만드는 타입을 말한다. 대수 타입에는 합집합 타입과 교집합 타입이 존재하며, 각각 Union 타입, Intersection 타입이라고 불린다.

합집합(Union) 타입

다음과 같이 |를 이용해 string과 number의 유니온 타입을 정의할 수 있다.

let a: string | number;

a = 1;
a = "hello";

유니온 타입에 참여하는 타입의 개수에는 제한이 없다. 따라서 추가하고 싶은 타입이 있을 경우 옆으로 계속 추가해서 작성해주면 된다.

배열 타입 정의

유니온 타입을 이용하면 다양한 타입의 요소를 보관하는 배열타입을 손쉽게 정의할 수 있다.

let arr: (number | string | boolean)[] = [1, "hello", true];

유니온과 객체 타입

다음과 같이 여러 객체 타입의 유니온 타입도 얼마든지 정의할 수 있다.

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

type Person = {
  name: string;
  language: string;
};

type DogPerson = Dog | Person;

이렇게 정의한 DorPerson 타입은 다음과 같이 교집합이 존재하는 두 집합으로 표현될 수 있다.

따라서 다음과 같은 객체들을 포함하는 타입이 된다.

let union1: DogPerson = {
  name: "",
  color: "",
};

let union2: DogPerson = {
  name: "",
  language: "",
};

let union3: DogPerson = {
  name: "",
  color: "",
  language: "",
};

반면 다음과 같은 객체는 포함하지 않는다.

let union4: DogPerson = {
  name: "",
};

이를 그림으로 표현하면 다음과 같다.

교집합(Intersection) 타입

다음과 같이 &를 이용해 string과 number의 인터섹션 타입을 정의할 수 있다.

let variable: number & string; // 교집합이 없기에 never 타입으로 추론된다

대다수의 기본 타입들 간에는 서로 공유하는 교집합이 없기 때문에 이러한 인터섹션 타입은 보통 객체 타입들에서 자주 사용된다.

인터섹션과 객체 타입

다음은 두 객체 타입의 인터센션타입을 정의하는 예이다.

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

type Person = {
  name: string;
  language: string;
};

type Intersection = Dog & Person;

let intersection1: Intersection = {
  name: "",
  color: "",
  language: "",
};

위 코드의 인터섹션 타입을 집합으로 표현하면 다음과 같다.


출처

위 포스트는 이정환 님의 인프런: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)를 수강한 뒤 복습 차원에서 저의 생각을 정리 및 추가하여 업로드했음을 알립니다.

댓글남기기