
지난 시간에는 타입스크립트의 기본적인 선언과 타입 시스템의 원리에 대해 알아보았습니다. 이번에는 타입스크립트가 내부적으로 타입을 어떻게 분류하고, 어떤 기준으로 서로 호환되는지 '타입 계층도'를 통해 깊이 있게 파고들어 보겠습니다.
출처: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)
타입스크립트를 이해하는 가장 중요한 관점은 타입은 값들의 집합이라는 점입니다.
- 핵심 원칙: 업캐스팅은 허용되고, 다운캐스팅은 금지된다.
- 자식 타입의 값을 부모 타입의 변수에 넣는 것(업캐스팅)은 가능합니다. (안전)
- 부모 타입의 값을 자식 타입의 변수에 넣는 것(다운캐스팅)은 불가능합니다. (위험)
let num1: number = 10; // 슈퍼 타입 (number 전체)
let num2: 10 = 10; // 서브 타입 (10이라는 특정 리터럴)
num1 = num2; // ✅ 가능 (업캐스팅)
num2 = num1; // ‼️ 에러 (다운캐스팅 불가능)
타입 계층도의 꼭대기에는 모든 타입을 포함할 수 있는 '전체 집합'이 존재합니다.
모든 값을 저장할 수 있지만, 반대로 모든 타입의 변수에 대입할 수는 없습니다.
function unknownExam() {
let unknownVar: unknown;
unknownVar = "";
unknownVar = 1;
unknownVar = () => {};
let num: number = unknownVar; // ‼️ 에러 (다운캐스팅 불가)
let str: string = unknownVar; // ‼️ 에러 (다운캐스팅 불가)
}
타입 계층도를 무시하는 치트키 같은 타입입니다. 유일하게 다운캐스팅이 허용되는 예외적인 존재입니다.
function anyExam() {
let unknownVar: unknown;
let anyVar: any;
let neverVar: never;
anyVar = unknownVar; // ✅ 가능 (예외적으로 다운캐스팅 허용)
let num: number = anyVar; // ✅ 가능 (치트키)
// 단, 아무리 any라도 never 타입에 대입할 수는 없습니다.
// neverVar = anyVar; // ‼️ 불가능
}
타입 계층도에서 void는 아무것도 반환하지 않는 함수의 타입을 의미하며, undefined를 자식으로 두는 부모(슈퍼) 타입입니다.
함수에서 값을 반환하지 않으면 자바스크립트는 기본적으로 undefined를 반환합니다. 따라서 void 타입에 undefined 값을 넣는 것은 자연스러운 업캐스팅이 됩니다.
function voidExam() {
function voidFunc(): void {
console.log("hi");
// 실제로는 return undefined; 가 생략된 것과 같음
}
let voidVar: void = undefined; // ✅ 가능 (업캐스팅)
}
실무에서 undefined를 가장 많이 마주치는 순간은 객체의 선택적 프로퍼티(?)를 사용할 때입니다. 프로퍼티 뒤에 ?를 붙이면, 해당 속성은 '해당 타입 | undefined'인 유니온 타입으로 정의됩니다.
type User = {
name: string;
age?: number; // 사실상 number | undefined 와 같음
};
let user1: User = { name: "오소리" }; // age는 undefined 상태
let user2: User = { name: "오소리", age: 25 };
"값이 없다"는 측면에서 void와 undefined는 비슷해 보이지만, 용도가 명확히 다릅니다.
void: 주로 함수의 반환값에서 "이 함수의 결과물에 신경 쓰지 마세요"라는 의도로 사용됩니다.undefined: 주로 변수나 객체의 프로퍼티에서 "현재 이 값이 할당되지 않았음"을 명시적으로 나타낼 때 사용합니다.never 타입은 실무에서 직접 변수에 지정할 일은 드물지만, 타입 시스템의 안정성을 지탱하는 아주 중요한 '안전장치' 역할을 합니다. 실전 사용처를 포함하여 내용을 재구성해 드릴게요.
never는 공집합을 의미합니다. 수학적으로 집합에 아무런 원소가 없는 상태처럼, 어떤 값도 가질 수 없는 불가능한 상태를 나타내는 타입입니다.
never 타입 변수에 대입될 수 없습니다. (심지어 any조차 불가능합니다.)function neverExam() {
function neverFunc(): never {
while (true) {}
}
let num: number = neverFunc(); // ✅ 가능 (어디든 들어갈 수 있음)
let str: string = neverFunc(); // ✅ 가능
let neverVar: never;
// neverVar = 1; // ‼️ 에러
// neverVar = "str"; // ‼️ 에러
// neverVar = anyVar; // ‼️ 에러 (any조차 감당 안 됨)
}
"직접 쓸 일이 있나?" 싶지만, never는 다음과 같은 상황에서 우리 코드를 지켜줍니다.
모든 케이스를 다 처리했는지 검사할 때 사용합니다. 만약 새로운 타입이 추가되었는데 처리를 깜빡했다면 never가 에러를 띄워줍니다.
type Drink = "Coffee" | "Juice" | "Tea";
function order(drink: Drink) {
switch (drink) {
case "Coffee": break;
case "Juice": break;
// ‼️ 만약 "Tea" 케이스를 빼먹었다면?
default:
// Tea가 처리되지 않아 이 코드로 넘어오게 되고,
// never 타입 변수에 Tea가 할당되려 하므로 컴파일 에러가 발생합니다!
const check: never = drink;
}
}
함수가 정상적으로 끝나지 않는 경우(중간에 에러를 던지거나 무한 반복될 때) 반환 타입으로 지정합니다.
function throwError(message: string): never {
throw new Error(message);
}
나중에 다룰 고급 타입에서, 특정 조건을 만족하지 않는 타입을 제거하고 싶을 때 never를 사용합니다.
// T가 number면 제외(never)하고, 아니면 그대로 유지해라!
type ExcludeNumber<T> = T extends number ? never : T;
type Result = ExcludeNumber<string | number>; // 결과는 string만 남음
객체 타입 간의 호환성은 '이름'이 아니라 '구조'를 기준으로 결정됩니다. (구조적 타입 시스템)
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;
// ‼️ 에러: 자식 타입 변수에 부모 객체를 넣는 것 (다운캐스팅)
// dog = animal;
객체 리터럴을 직접 대입할 때는 정의되지 않은 속성이 있으면 에러가 발생합니다.
let animal2: Animal = {
name: "진도",
color: "white",
// breed: "진도" // ‼️ 에러 (초과 프로퍼티 검사)
};
변수에 담아서 대입할 때는 통과되지만, 리터럴로 직접 줄 때는 타입스크립트가 엄격하게 검사한다는 점을 유의해야 합니다.
타입스크립트의 타입 시스템은 단순히 타입을 나열하는 것이 아니라, 거대한 계층도(Hierarchy)를 통해 전체 구조를 체계적으로 관리합니다.
unknown부터 최하위의 never까지, 각 타입이 가지는 집합의 크기를 기준으로 상하 관계를 설정하여 데이터의 흐름을 통제합니다.결국 타입 계층도를 이해한다는 것은 타입스크립트가 우리 코드를 어떤 질서로 관리하고 보호하는지 그 설계 원리를 파악하는 것과 같습니다. 이 질서를 이해하면 복잡한 타입 에러 앞에서도 당황하지 않고 정확한 해결책을 찾을 수 있습니다.