요즘IT
위시켓
AIDP - AX
Rise ERP
콘텐츠프로덕트 밸리
요즘 작가들컬렉션물어봐
놀이터
콘텐츠
프로덕트 밸리
요즘 작가들
컬렉션
물어봐
놀이터
새로 나온
인기
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘ITAIDP - AXRise ERP
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
yozm_help@wishket.com
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

타입 계층과 호환성, extends가 진짜 의미하는 것

효빈
12분
1시간 전
66
에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중

타입스크립트를 사용하다 보면 extends라는 키워드를 다양한 곳에서 만나게 됩니다. Conditional Type에서는 T extends U ? X : Y 형태로, 제네릭에서는 T extends string 같은 제약 조건으로 등장합니다. 두 경우 모두 "할당 가능하면"이라는 설명이 따라붙는데, 정확히 어떤 조건에서 할당이 가능하고 어떤 조건에서 불가능한 것일까요?

 

타입 좁히기에서도 비슷한 의문이 생깁니다. typeof 검사를 거치면 "타입이 좁아진다"고 하는데, 넓은 타입과 좁은 타입이라는 말은 타입 사이에 일종의 크기 관계가 있다는 뜻입니다. 그렇다면 이 크기 관계는 어디에서 오는 것이고, 타입스크립트는 어떤 기준으로 이를 판단하는 것일까요?

 

이 관계를 체계적으로 정리한 것이 타입 계층도이고, 이를 기반으로 타입스크립트가 할당 가능 여부를 판단하는 규칙이 타입 호환성입니다. 이번 글에서는 타입 계층도의 전체 구조를 살펴보고, 타입스크립트가 타입 간의 관계를 어떤 기준으로 판단하는지 알아보겠습니다. 이 개념을 이해하고 나면, extends와 타입 좁히기, 유틸리티 타입의 동작 원리가 한층 더 선명해질 것입니다.

 

미리 요점만 콕 집어보면?

  • 타입스크립트의 모든 타입은 unknown부터 never까지 계층을 이루며, 좁은 타입에서 넓은 타입으로만 안전하게 할당됩니다.
  • 객체 타입은 이름이 아니라 구조를 기준으로 호환성을 판단하며, extends 역시 결국 “할당 가능한가?”를 검사하는 문법입니다.
  • Conditional Type, 제네릭 제약, 타입 좁히기는 모두 같은 타입 계층과 호환성 규칙 위에서 동작하는 개념입니다.
 

타입 계층도, 모든 타입에는 상하 관계가 있다

타입스크립트에서 사용하는 모든 타입은 하나의 계층 구조 안에 놓여 있습니다. 이 구조를 이해하면 어떤 타입이 어떤 타입에 할당 가능한지, 왜 특정 할당이 오류를 발생시키는지를 체계적으로 파악할 수 있습니다. 이후 섹션에서 다룰 호환성 규칙과 extends의 의미가 모두 이 계층도에 뿌리를 두고 있으므로, 먼저 전체 그림을 잡고 가겠습니다.

 

1) unknown과 never

타입 계층도의 맨 꼭대기에는 unknown이, 맨바닥에는 never가 위치합니다. 이 두 타입은 계층의 양극단을 이루는 특별한 존재입니다.

 

unknown은 모든 타입의 슈퍼타입, 즉 최상위 타입입니다. 어떤 값이든 unknown 타입 변수에 할당할 수 있습니다. 외부 API로부터 어떤 형태의 데이터가 들어올지 알 수 없는 상황에서 유용하게 사용됩니다.

 

let a: unknown;

a = 1;         // number → unknown ✅
a = "hello";   // string → unknown ✅
a = true;      // boolean → unknown ✅

 

모든 값을 받아들일 수 있다는 점에서 any와 비슷해 보이지만, 결정적인 차이가 있습니다. unknown 타입의 값은 다른 타입 변수에 할당할 수 없습니다. 즉, 들어오는 것은 자유롭지만 나가는 것은 엄격하게 제한됩니다.

 

let a: unknown = "hello";
let b: string = a;
// 오류: 'unknown' 형식은 'string' 형식에 할당할 수 없습니다.

 

반대쪽 끝에 있는 never는 모든 타입의 서브타입, 즉 최하위 타입입니다. never는 "절대 존재할 수 없는 값"을 나타내며, 어떤 값도 never 타입 변수에 할당할 수 없습니다. 실제로 never 타입이 되는 대표적인 경우는 항상 예외를 던지거나 무한 루프를 도는 함수의 반환 타입입니다. 이런 함수는 정상적으로 값을 반환하는 일이 절대 없으므로, 반환 타입이 never가 됩니다. 그런데 흥미로운 점은, never 자체는 모든 타입에 할당할 수 있다는 것입니다.

 

function throwError(): never {
  throw new Error("에러 발생");
}

let a: number = throwError(); // never → number ✅
let b: string = throwError(); // never → string ✅

 

Conditional Type을 사용할 때 결과로 never가 자주 등장하는 것을 본 적이 있을 것입니다. type MyExclude<T, U> = T extends U ? never : T에서 조건에 해당하는 멤버를 never로 만들면 유니온에서 자동으로 사라집니다. 이것이 가능한 이유가 바로 never가 최하위 타입이기 때문입니다. never는 값의 집합 관점에서 보면 아무 값도 포함하지 않는 공집합에 해당합니다. 공집합을 다른 집합과 합쳐도 결과가 달라지지 않는 것처럼, never는 유니온에 포함되어도 아무런 영향을 주지 않습니다.

 

2) any

any는 앞서 살펴본 타입 계층도 안에 위치한 타입이 아닙니다. unknown이나 never처럼 상하 관계로 설명할 수 있는 타입이 아니라, 타입 검사 자체를 우회하는 특별한 타입입니다.

 

let a: any;

// 모든 타입으로부터 할당받을 수 있음
a = 1;
a = "hello";
a = true;

// 모든 타입에 할당할 수도 있음
let b: number = a;  // any → number ✅
let c: string = a;  // any → string ✅

 

모든 타입에 할당할 수도 있고, 모든 타입으로부터 할당받을 수도 있습니다. 얼핏 보면 unknown(모든 것을 받아들이는)과 never(모든 곳에 할당 가능한)의 성질을 동시에 가진 것처럼 보이지만, 실제로는 성격이 완전히 다릅니다. unknown과 never는 계층 규칙을 철저히 따르면서 안정성을 보장하는 반면, any는 그 규칙 자체를 비활성화합니다.

 

any를 사용하면 타입 검사가 사실상 무력화됩니다. 숫자가 들어와야 할 자리에 문자열이 들어와도 컴파일러는 아무런 경고를 하지 않습니다. 타입스크립트를 사용하는 가장 큰 이유가 타입 안정성인데, any는 그 안정성을 스스로  포기하는 선택입니다. 따라서 가능한 한 unknown으로 대체하고, 타입 좁히기를 통해 안전하게 사용하는 것이 권장됩니다.

 

3) 기본 타입들의 위치

unknown과 never 사이에는 우리가 일상적으로 사용하는 타입들이 계층을 이루고 있습니다. string, number, boolean 같은 원시 타입은 계층의 중간에 위치하고, 리터럴 타입은 그 아래에 위치합니다.

 

let a: string = "hello";   // 리터럴 → 원시 타입 ✅
let b: "hello" = "hello";  // 리터럴 → 리터럴 ✅

let c: "hello" = a;
// 오류: 'string' 형식은 '"hello"' 형식에 할당할 수 없습니다.

 

"hello"는 string의 서브타입입니다. 따라서 "hello"를 string 변수에 넣는 것은 가능하지만, 반대로 string을 "hello” 변수에 넣는 것은 불가능합니다. string은 “hello" 뿐 아니라 "world", "abc"등 무한히 많은 문자열을 포함하는 더 넓은 타입이기 때문입니다.

 

이 관계를 떠올리면, 타입 좁히기가 왜 "좁아진다"는 표현을 쓰는지 자연스럽게 이해됩니다. typeof value === "string" 검사를 통과하면 string | number라는 넓은 타입에서 string이라는 좁은 타입으로 내려가는 것이고, 이는 타입 계층도에서 아래쪽으로 이동하는 것과 같습니다. 숫자 타입도 마찬가지입니다. number는 가능한 모든 숫자를 포함하는 넓은 타입이고, 42는 그중 딱 하나의 값만 허용하는 좁은 타입입니다. 이처럼 리터럴 타입은 원시 타입이 허용하는 값의 범위를 하나로 한정한 것이라고 이해하면 됩니다.

 

정리하면 타입 계층도의 큰 그림은 다음과 같습니다. 위에서부터 unknown -> string, number, boolean 등 원시 타입 -> “hello", 42 등 리터럴 타입 -> never 순서이며, any는 이 계층 바깥에서 모든 규칙을 우회하는 예외적 존재입니다. 참고로 이 계층도는 완전한 트리 구조라기보다, 타입 간의 포함 관계를 나타낸 것으로 이해하는 것이 더 정확합니다.

 

<출처: 작가, GPT로 생성>

 

 

타입 호환성

타입 계층도가 타입 간의 상하 관계를 보여주는 지도라면, 타입 호환성은 그 지도를 기반으로 실제 할당이 가능한지를 판단하는 규칙입니다. 이 절에서는 업캐스팅과 다운캐스팅의 개념을 먼저 살펴본 뒤, 객체 타입에서 특히 중요한 구조적 타이핑과 초과 프로퍼티 검사까지 알아보겠습니다.

 

1) 업캐스팅과 다운캐스팅

타입 호환성의 핵심 규칙은 간단합니다. 좁은 타입에서 넓은 타입으로의 할당은 허용되고, 넓은 타입에서 좁은 타입으로의 할당은 차단됩니다. 좁은 타입을 넓은 타입에 할당하는 것을 업캐스팅이라고 합니다. 리터럴 42를 number 변수에 넣는 것이 대표적인 업캐스팅입니다. 42는 number가 허용하는 값 중 하나이므로 안전합니다.

 

let a: number = 42;       // 42(리터럴) → number ✅ 업캐스팅
let b: unknown = "hello"; // string → unknown ✅ 업캐스팅

 

반대로 넓은 타입을 좁은 타입에 할당하는 것을 다운캐스팅이라고 합니다. number를 42 타입 변수에 넣으려 하면 오류가 발생합니다. number에는 42 외에도 0, -1, 3.14 등 수많은 값이 포함되어 있으므로, 42만 허용하는 변수에 넣는 것은 안전하지 않기 때문입니다.

 

let a: number = 100;
let b: 42 = a;
// 오류: 'number' 형식은 '42' 형식에 할당할 수 없습니다.

 

이 규칙은 모든 타입에 일관되게 적용됩니다. string은 unknown에 할당 가능하고(업캐스팅), unknown을 string에 넣는 것(다운캐스팅)은 불가능합니다. never의 경우에는 앞서 살펴본 것처럼 가능한 값이 하나도 없는 공집합이므로, 모든 타입의 부분집합으로 간주되어 어디에든 할당할 수 있습니다. 반대로 string을 never에 넣는 것은 불가능합니다.

 

참고로 여기서 사용한 업캐스팅과 다운캐스팅이라는 용어는 이해를 돕기 위한 비유입니다. 원래 이 용어는 Java나 C#처럼 명목적 상속 구조를 가진 언어에서 유래한 것으로, 구조적 타입 시스템을 사용하는 타입스크립트와 완전히 동일한 개념은 아닙니다. 다만 "좁은 타입에서 넓은 타입으로 올라간다", "넓은 타입에서 좁은 타입으로 내려간다"는 방향성은 같으므로, 직관적으로 이해하기 위해 빌려 쓴 것입니다. 

 

왜 이런 규칙이 있는 것일까요? 업캐스팅이 안전한 이유는 간단합니다. 좁은 타입의 값은 넓은 타입이 허용하는 범위 안에 항상 포함되기 때문입니다. 42는 분명히 숫자이므로 number 변수에 넣어도 문제가 없습니다. 하지만 다운캐스팅은 다릅니다. number에는 42뿐 아니라 0, -1, 3.14 등 무수히 많은 값이 포함되어 있으므로, 이를 42만 허용하는 변수에 넣으면 런타임에 예상치 못한 값이 들어올 위험이 있습니다. 

 

타입스크립트는 이 위험을 컴파일 타임에 미리 차단해 주는 것입니다. 다만 함수 타입에서는 매개변수 위치에 반공변성(contravariance)이 적용되는 등, 값 타입과는 조금 다른 규칙이 존재합니다. 이 글에서는 값 타입 중심의 기본적인 호환성 규칙에 집중하겠습니다. 

 

2) 객체 타입의 호환성

원시 타입의 호환성은 직관적이지만, 객체 타입에서는 조금 다른 기준이 적용됩니다. 타입스크립트는 객체 타입의 호환성을 판단할 때 타입의 이름이 아니라 구조, 즉 어떤 프로퍼티를 가지고 있는지를 기준으로 삼습니다. 이를 구조적 타이핑(Structural Typing)이라고 합니다.

 

interface User {
  name: string;
}

interface Employee {
  name: string;
  department: string;
}

let user: User = { name: "kim" };
let employee: Employee = { name: "lee", department: "dev" };

user = employee; // ✅ Employee → User 할당 가능

 

User와 Employee는 이름이 완전히 다른 타입이지만, Employee는 User가 요구하는 name: string 프로퍼티를 모두 갖고 있습니다. 타입스크립트는 이것만으로 할당이 가능하다고 판단합니다.

 

여기서 중요한 점은, 대상 타입이 요구하는 구조를 모두 만족하는지가 호환성의 기준이라는 것입니다. 구조적 타입 시스템에서는 대체로 더 많은 요구 사항(프로퍼티)을 가진 타입이 더 구체적(좁은) 타입으로 취급됩니다. Employee는 User가 요구하는 name: string을 갖고 있으면서 department까지 추가로 가지고 있으므로, User보다 더 구체적인 타입이고 따라서 User에 할당 가능합니다. 반대로 User를 Employee에 할당하려 하면 department 프로퍼티가 빠져 있으므로 오류가 발생합니다.

 

employee = user;
// 오류: 'department' 속성이 'User' 형식에 없습니다.

 

이 규칙은 Java나 C#처럼 타입의 이름으로 호환성을 판단하는 명목적 타이핑(Nominal Typing)과 대조됩니다. 명목적 타이핑에서는 User와 Employee가 명시적으로 상속 관계를 선언하지 않는 한 서로 호환되지 않습니다. 이름이 다르면 구조가 아무리 비슷해도 별개의 타입으로 취급합니다. 하지만 타입스크립트는 구조만 맞으면 호환된다고 판단하므로, 별도의 상속 선언 없이도 유연하게 타입을 다룰 수 있습니다. 자바스크립트 생태계에서는 서로 다른 라이브러리가 비슷한 구조의 객체를 주고받는 일이 잦기 때문에, 이러한 구조적 타이핑이 실무에서 큰 장점으로 작용합니다.

 

<출처: 작가, Claude AI로 생성>

 

3) 초과 프로퍼티 검사

구조적 타이핑의 규칙대로라면, 프로퍼티가 더 많은 객체는 항상 할당 가능해야 합니다. 하지만 한 가지 예외가 있습니다. 객체 리터럴을 직접 할당할 때는 정의되지 않은 프로퍼티가 있으면 오류가 발생합니다.

 

interface User {
  name: string;
}

let user: User = {
  name: "kim",
  age: 25,
  // 오류: '{ name: string; age: number; }' 형식은 'User' 형식에 할당할 수 없습니다.
  // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'User' 형식에 'age'이(가) 없습니다.
};

 

이를 초과 프로퍼티 검사(Excess Property Check)라고 합니다. 이 검사는 개발자가 오타를 내거나, 존재하지 않는 프로퍼티를 실수로 작성하는 것을 방지하기 위한 안전장치입니다.

 

그런데 같은 객체를 변수에 먼저 담은 뒤 할당하면 이 검사를 우회할 수 있습니다.

 

const person = { name: "kim", age: 25 };
let user: User = person; // ✅ 오류 없음

 

변수 person에 담긴 시점에서 타입스크립트는 person의 타입을 { name: string; age: number }로 추론합니다. 이후 user에 할당할 때는 일반적인 구조적 타이핑 규칙이 적용되므로, name: string을 갖고 있는 것만 확인하고 통과시킵니다. 초과 프로퍼티 검사는 오직 객체 리터럴을 직접 할당할 때만 동작한다는 점을 기억해 두면 좋습니다.

 

이 동작이 처음에는 일관성이 없어 보일 수 있지만, 실용적인 관점에서 보면 합리적입니다. 객체 리터럴을 직접 작성할 때는 개발자가 의도한 프로퍼티만 정확히 넣었는지 확인하는 것이 유용합니다. 반면에 이미 만들어진 객체를 전달할 때는, 그 객체가 필요한 프로퍼티를 모두 갖고 있다면 추가 프로퍼티가 있어도 문제가 되지 않기 때문입니다. 

 

 

extends 다시 보기

타입 계층도와 호환성 규칙을 이해했으니, 이제 extends 키워드의 의미를 정확하게 짚어볼 수 있습니다. extends는 타입스크립트에서 주로 "할당 가능한가(assignable to)?"를 검사하는 데 사용됩니다. 대부분의 경우 이것은 서브타입 관계와 동일하게 동작하지만, 앞서 살펴본 any처럼 계층 규칙을 벗어나는 특수한 타입이 있으므로 엄밀히 말하면 완전히 같은 개념은 아닙니다. 다만 일반적인 타입을 다루는 상황에서는 "서브타입인가?"와 "할당 가능한가?"를 거의 같은 의미로 이해해도 무방합니다.

 

1) Conditional Type의 extends

Conditional Type의 기본 형태를 다시 살펴보겠습니다.

 

type IsString<T> = T extends string ? true : false;

 

여기서 T extends string은 "T가 string의 서브타입인가?", 다시 말해 "T가 string에 할당 가능한가?"를 묻는 것입니다. 타입 계층도에서 T가 string보다 아래(더 좁은 위치)에 있으면 참이 됩니다.

 

type A = IsString<"hello">; // true — "hello"는 string의 서브타입
type B = IsString<42>;      // false — 42는 string의 서브타입이 아님

 

"hello"가 true가 되는 이유는 앞서 살펴본 것처럼 리터럴 타입이 원시 타입의 서브타입이기 때문입니다. 타입 계층도에서 "hello"는 string 아래에 위치하므로 extends 검사를 통과합니다.

 

Exclude의 구현에도 같은 원리가 적용됩니다. type MyExclude<T, U> = T extends U ? never : T에서 유니온의 각 멤버가 U의 서브타입인지를 검사하고, 서브타입이면 never로 제거하는 것입니다. 계층 구조를 알고 나면, 이 동작이 더 이상 마법처럼 느껴지지 않습니다. 참고로 분배적 조건부 타입의 동작도 결국 이 서브타입 검사가 유니온의 각 멤버에 개별적으로 적용되는 것이라는 점을 떠올려보면, extends의 의미가 한 층 더 명확해집니다.

 

2) 제네릭 제약 조건의 extends

제네릭에서 사용되는 extends도 같은 뿌리에서 출발합니다. 다음 예시를 살펴보겠습니다.

 

function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("hello");    // ✅ string은 length를 가짐
getLength([1, 2, 3]);  // ✅ 배열은 length를 가짐
getLength(42);
// 오류: 'number' 형식은 '{ length: number }' 형식의 제약 조건을 만족하지 않습니다.

 

T extends { length: number }는 "T는 { length: number }의 서브타입이어야 한다"는 제약입니다. 구조적 타이핑의 관점에서 보면, T가 최소한 length: number 프로퍼티를 갖고 있어야 한다는 의미입니다.

 

string은 length 프로퍼티를 가지고 있으므로 { length: number }의 서브타입이고, number는 length 프로퍼티가 없으므로 서브타입이 아닙니다. 배열 역시 length 프로퍼티를 가지고 있으므로 제약을 만족합니다. 이처럼 제네릭 제약 조건의 extends도 결국 구조적 타이핑을 기반으로 서브타입 여부를 검사하는 것입니다. 타입 계층도, 구조적 타이핑, 그리고 extends, 이 세 가지가 하나의 맥락으로 연결되는 것을 확인할 수 있습니다.

 

 

마치며

이번 글에서는 타입스크립트의 타입 계층도와 타입 호환성, 그리고 extends 키워드의 정확한 의미를 살펴보았습니다. 타입스크립트의 모든 타입은 unknown(최상위)부터 never(최하위)까지 하나의 계층을 이루고 있으며, 좁은 타입에서 넓은 타입으로의 할당만 안전하게 허용됩니다. 객체 타입의 경우에는 이름이 아닌 구조를 기준으로 호환성을 판단하는 구조적 타이핑이 적용되고, 대체로 더 많은 프로퍼티를 요구하는 쪽이 더 좁은(구체적인) 타입이 됩니다.

 

이 규칙을 이해하고 나면, 여러 문법에서 등장하던 개념들이 하나의 원리로 연결됩니다. Conditional Type에서 T extends U는 "T가 U에 할당 가능한가?"를 묻는 것이고, 제네릭의 T extends { length: number }는 "T가 이 구조에 할당 가능해야 한다"는 제약이며, 타입 좁히기에서 타입이 "좁아진다"는 것은 계층도에서 아래쪽의 더 구체적인 타입으로 이동한다는 뜻입니다. 결국 같은 규칙이 서로 다른 문법으로 표현되고 있었던 셈입니다. 타입 계층도라는 하나의 지도를 손에 쥐고 나면, 앞으로 어떤 새로운 타입 문법을 만나더라도 그 동작을 스스로 추론해 볼 수 있을 겁니다.


<출처> 

  • 한 입 크기로 잘라먹는 타입스크립트 - 타입 단언
  • 한 입 크기로 잘라먹는 타입스크립트 - 타입 계층도와 함께 기본타입 살펴보기

 

©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.