[TypeScript] 타입 계층도 파악하기

DevBadger·2026년 2월 9일

TypeScript

목록 보기
5/5
post-thumbnail

지난 시간에는 타입스크립트의 기본적인 선언과 타입 시스템의 원리에 대해 알아보았습니다. 이번에는 타입스크립트가 내부적으로 타입을 어떻게 분류하고, 어떤 기준으로 서로 호환되는지 '타입 계층도'를 통해 깊이 있게 파고들어 보겠습니다.

변수 이미지
출처: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)

1. 타입은 '집합'이다

타입스크립트를 이해하는 가장 중요한 관점은 타입은 값들의 집합이라는 점입니다.

  • 슈퍼 타입(부모 타입): 더 큰 범위를 포함하는 집합
  • 서브 타입(자식 타입): 더 작은 범위를 포함하는 집합
  • 핵심 원칙: 업캐스팅은 허용되고, 다운캐스팅은 금지된다.
    • 자식 타입의 값을 부모 타입의 변수에 넣는 것(업캐스팅)은 가능합니다. (안전)
    • 부모 타입의 값을 자식 타입의 변수에 넣는 것(다운캐스팅)은 불가능합니다. (위험)
let num1: number = 10;     // 슈퍼 타입 (number 전체)
let num2: 10 = 10;         // 서브 타입 (10이라는 특정 리터럴)

num1 = num2; // ✅ 가능 (업캐스팅)
num2 = num1; // ‼️ 에러 (다운캐스팅 불가능)

2. 최상위 계층: Unknown과 Any

타입 계층도의 꼭대기에는 모든 타입을 포함할 수 있는 '전체 집합'이 존재합니다.

Unknown 타입 (Safe Top Type)

모든 값을 저장할 수 있지만, 반대로 모든 타입의 변수에 대입할 수는 없습니다.

function unknownExam() {
  let unknownVar: unknown;

  unknownVar = "";
  unknownVar = 1;
  unknownVar = () => {};

  let num: number = unknownVar; // ‼️ 에러 (다운캐스팅 불가)
  let str: string = unknownVar; // ‼️ 에러 (다운캐스팅 불가)
}

Any 타입 (The Exception)

타입 계층도를 무시하는 치트키 같은 타입입니다. 유일하게 다운캐스팅이 허용되는 예외적인 존재입니다.

function anyExam() {
  let unknownVar: unknown;
  let anyVar: any;
  let neverVar: never;

  anyVar = unknownVar; // ✅ 가능 (예외적으로 다운캐스팅 허용)
  
  let num: number = anyVar; // ✅ 가능 (치트키)
  
  // 단, 아무리 any라도 never 타입에 대입할 수는 없습니다.
  // neverVar = anyVar; // ‼️ 불가능
}

3. 중간 계층: Void와 Undefined

타입 계층도에서 void는 아무것도 반환하지 않는 함수의 타입을 의미하며, undefined를 자식으로 두는 부모(슈퍼) 타입입니다.

3-1. 업캐스팅으로서의 관계

함수에서 값을 반환하지 않으면 자바스크립트는 기본적으로 undefined를 반환합니다. 따라서 void 타입에 undefined 값을 넣는 것은 자연스러운 업캐스팅이 됩니다.

function voidExam() {
  function voidFunc(): void {
    console.log("hi");
    // 실제로는 return undefined; 가 생략된 것과 같음
  }

  let voidVar: void = undefined; // ✅ 가능 (업캐스팅)
}

3-2. 실전 활용: 선택적 프로퍼티 (Optional Property)

실무에서 undefined를 가장 많이 마주치는 순간은 객체의 선택적 프로퍼티(?)를 사용할 때입니다. 프로퍼티 뒤에 ?를 붙이면, 해당 속성은 '해당 타입 | undefined'인 유니온 타입으로 정의됩니다.

type User = {
  name: string;
  age?: number; // 사실상 number | undefined 와 같음
};

let user1: User = { name: "오소리" }; // age는 undefined 상태
let user2: User = { name: "오소리", age: 25 };

3-3. Void와 선택적 프로퍼티의 차이점

"값이 없다"는 측면에서 voidundefined는 비슷해 보이지만, 용도가 명확히 다릅니다.

  • void: 주로 함수의 반환값에서 "이 함수의 결과물에 신경 쓰지 마세요"라는 의도로 사용됩니다.
  • undefined: 주로 변수나 객체의 프로퍼티에서 "현재 이 값이 할당되지 않았음"을 명시적으로 나타낼 때 사용합니다.

never 타입은 실무에서 직접 변수에 지정할 일은 드물지만, 타입 시스템의 안정성을 지탱하는 아주 중요한 '안전장치' 역할을 합니다. 실전 사용처를 포함하여 내용을 재구성해 드릴게요.


4. 최하위 계층: Never (Bottom Type)

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는 다음과 같은 상황에서 우리 코드를 지켜줍니다.

4-1. 철저한 조건부 검사 (Exhaustive Check)

모든 케이스를 다 처리했는지 검사할 때 사용합니다. 만약 새로운 타입이 추가되었는데 처리를 깜빡했다면 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; 
  }
}

4-2. 의도적인 에러 발생 및 무한 루프

함수가 정상적으로 끝나지 않는 경우(중간에 에러를 던지거나 무한 반복될 때) 반환 타입으로 지정합니다.

function throwError(message: string): never {
  throw new Error(message);
}

4-3. 타입 조작 및 필터링

나중에 다룰 고급 타입에서, 특정 조건을 만족하지 않는 타입을 제거하고 싶을 때 never를 사용합니다.

// T가 number면 제외(never)하고, 아니면 그대로 유지해라!
type ExcludeNumber<T> = T extends number ? never : T;

type Result = ExcludeNumber<string | number>; // 결과는 string만 남음

5. 객체 타입의 호환성

객체 타입 간의 호환성은 '이름'이 아니라 '구조'를 기준으로 결정됩니다. (구조적 타입 시스템)

  • 조건(프로퍼티)이 적을수록 더 넓은 범위의 슈퍼 타입이 됩니다.
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까지, 각 타입이 가지는 집합의 크기를 기준으로 상하 관계를 설정하여 데이터의 흐름을 통제합니다.
  • 호환성의 관리: "이 변수에 이 값을 넣어도 안전한가?"라는 질문에 대해, 계층도상의 '업캐스팅' 여부를 판단 근거로 삼아 런타임 에러를 사전에 차단합니다.
  • 확장의 관리: 객체 타입에 속성이 추가될수록 계층도의 아래쪽(자식)으로 배치되는 원리를 통해, 타입이 구체화될수록 더 엄격한 관리가 이루어지도록 설계되어 있습니다.

결국 타입 계층도를 이해한다는 것은 타입스크립트가 우리 코드를 어떤 질서로 관리하고 보호하는지 그 설계 원리를 파악하는 것과 같습니다. 이 질서를 이해하면 복잡한 타입 에러 앞에서도 당황하지 않고 정확한 해결책을 찾을 수 있습니다.

profile
개발 오소리

0개의 댓글