
타입들 사이에는 계층이 있습니다. 어떤 타입은 다른 타입의 상위에 존재하고, 어떤 타입은 모든 타입의 아래에 있습니다.
타입스크립트가 "이 할당은 되고, 저 할당은 안 된다"라고 판단하는 기준이 바로 이 계층에서 나옵니다.
타입 계층

타입스크립트에는 타입 계층도가 존재합니다.
맨 위에는 unknown, 맨 아래에는 never가 있고, 그 사이에 우리가 익숙하게 쓰는 string, numbver, boolean 같은 타입들이 위치합니다.
any는 조금 특별한 위치에 있는데, 이건 뒤에서 따로 다루겠습니다.
계층이 존재한다는 건 곧 상위 타입(슈퍼타입)과 하위 타입(서브 타입) 관계가 성립한다는 의미입니다.
서브타입은 슈퍼타입으로 할당할 수 있고(업캐스팅), 반대 방향(다운캐스팅)은 원칙적으로 허용되지 않습니다.
이 방향성이 타입스크립트가 할당 가능 여부를 판단하는 핵심 기준입니다.
unknown : 전체 집합
unknown은 모든 타입의 슈퍼타입입니다. 어떤 값이든 unknown 타입 변수에 할당할 수 있습니다.
let a : unknown = 1;
let b : unknown = "hi";
let c : unknown = true;
반대로 unknwon 타입의 값을 다른 타입 변수에 할당하는 건 허용되지 않습니다. 다운 캐스팅이기 때문입니다.
let unknwonVar : unknown;
let num : number = unknownVar; // 오류 발생
unknown이 존재하는 이유는 any의 무분별한 사용을 줄이기 위해서입니다.
타입을 알 수 없는 값을 받아야 하는 상황이 있을 때, any 대신 unknown을 쓰면 해당 값을 실제로 사용하기 전에 타입을 좁히는 과정을 강제할 수 있습니다.
never : 공집합
never는 계층의 반대 끝에 있습니다. 모든 타입의 서브타입이기 때문에 never 타입의 값은 어떤 타입에도 업캐스팅해서 할당할 수 있습니다.
function neverFunc() : never {
while (true) {}
}
let num : number = neverFunc(); // 업캐스팅이라 가능함
반면 never 변수에 다른 타입의 값을 할당하는 건 불가능합니다. 공집합이라 어떤 값도 속할 수 없기 때문입니다.
let neverVar : never = 1; // 오류
never는 절대 반환되지 않는 함수, 즉 무한 루프나 예외를 던지는 함수의 반환 타입으로 주로 등장합니다.
처음엔 다소 추상적으로 느껴지지만, 이후 조건부 타입이나 타입 좁히기에서 실질적으로 활용됩니다.
void : undefined의 슈퍼타입
void는 반환값이 없는 함수의 반환 타입으로 쓰입니다. 계층상으로는 undefined의 슈퍼타입입니다.
function voidFunc() : void {
console.log("hello");
}
let voidVar : void = undefined; // 가능
undefined를 반환 타입으로 직접 쓰면 return undefined를 명시해야 하지만, void는 반환문 자체를 생략할 수 있어 더 자연스럽습니다.
Any : 계층을 무시
any는 계층도 안에서 설명하기 어려운 타입입니다. 슈퍼타입처럼 모든 값을 받을 수 있고, 서브타입처럼 어떤 타입에도 할당할 수 있습니다.
never를 제외한 모든 방향의 캐스팅이 허용됩니다.
let anyVar: any;
let unknownVar: unknown;
let undefinedVar: undefined;
anyVar = unknownVar; // unknown → any, 가능
undefinedVar = anyVar; // any → undefined, 가능
// neverVar = anyVar; // never는 불가능
문제는 이 유연함이 타입 시스템 자체를 무력화한다는 점입니다. any를 쓰는 순간 그 변수에 대한 타입 검사가 사실상 사라집니다.
any를 남발하면 타입스크립트를 쓰는 이유가 없으므로 조심해야합니다.
타입 호환성
기본 타입의 계층은 타입스크립트가 이미 정해둔 것이지만, 객체 타입의 계층은 조금 다르게 결정됩니다.
타입스크립트는 구조적 타입 시스템을 따르기 때문에, 타입의 이름이 아니라 프로퍼티의 구조를 기준으로 상하 관계를 판단합니다.
type Animal = {
name: string;
color: string;
};
type Dog = {
name: string;
color: string;
breed: string;
};
얼핏 보면 프로퍼티가 더 많은 Dog가 더 구체적이니 슈퍼타입처럼 느껴질 수 있습니다. 그러나 실제로 반대입니다.
Animal이 슈퍼타입이고, Dog가 서브타입입니다.
이유는 조건의 수로 생각하면 이해하기 쉽습니다. Animal 타입이 되려면 name과 color만 있으면 됩니다.
Dog 타입이 되려면 거기에 breed까지 있어야 합니다. 조건이 더 많다는 건 더 좁은 범위를 가리킨다는 의미이고, 좁을수록 서브타입입니다.
let animal: Animal = { name: "기린", color: "yellow" };
let dog: Dog = { name: "똥개", color: "green", breed: "잡종" };
animal = dog; // 가능 — 업캐스팅
dog = animal; // 오류 — 다운캐스팅
Dog는 Animal의 조건을 모두 충족하므로 Animal로 취급할 수 있습니다. 반대로 Animal은 breed가 없을 수 있으니 Dog로 취급할 수 없습니다.
초과 프로퍼티 검사
객체 리터럴을 직접 변수에 할당할 때는 초과 프로퍼티 검사가 적용됩니다.
type Book = {
name: string;
price: string;
};
// 오류 — 객체 리터럴에 정의되지 않은 프로퍼티 포함
let book: Book = {
name: "코틀린",
price: "23000",
skill: "language", // 초과 프로퍼티
};
그런데 같은 객체를 변수를 통해서 넘기면 오류가 발생하지 않습니다.
type ProgrammingBook = {
name: string;
price: string;
skill: string;
};
let pBook: ProgrammingBook = { name: "코틀린", price: "23000", skill: "language" };
let book: Book = pBook; // 가능 — 변수를 통한 할당은 초과 프로퍼티 검사 없음
타입스크립트가 객체 리터럴에만 초과 프로퍼티 검사를 적용하는 건, 리터럴로 직접 쓰는 경우는 대부분 그 타입을 위해 만든 값이므로 오타나 실수일 가능성이 높다고 보기 때문입니다. 변수를 통한 할당은 이미 다른 용도로 만들어진 값을 재사용하는 상황이라 판단 기준이 달라집니다.
대수타입, 타입 추론
대수 타입
타입스크립트에서는 기존 타입들을 조합해 새로운 타입을 만들 수 있습니다.
합집합에 해당하는 유니온 타입과 교집합에 해당하는 인터섹션 타입, 두 가지가 존재합니다.
유니온 타입은 | 연산자로 만들고 나열된 타입 중 하나에 해당하면 됩니다.
let a: string | number = 1;
a = "hello"; // 둘 다 가능
객체 타입에 유니온을 적용하면 조금 더 생각이 필요합니다.
type Dog = { name: string; color: string };
type Person = { name: string; language: string };
type Union1 = Dog | Person;
let u1: Union1 = { name: "", color: "" }; // Dog — 가능
let u2: Union1 = { name: "", language: "" }; // Person — 가능
let u3: Union1 = { name: "", color: "", language: "" }; // 둘 다 — 가능
// let u4: Union1 = { name: "" }; // 오류 — Dog도 Person도 아님
여기서 name만 있는 u4가 객체가 안되는 이유는 Dog도 Person도 아니기 때문입니다.
유니온은 "둘 중 하나"이지, "공통 프로퍼티만 있으면 됨"이 아닙니다.
인터섹션 타입은 & 연산자로 만들며, 나열된 타입 모두를 동시에 만족해야 합니다.
type Intersection = Dog & Person;
let inter: Intersection = {
name: "",
color: "",
language: "", // 세 프로퍼티 모두 필요
};
교집합이라는 이름 때문에 name처럼 공통 프로퍼티만 있으면 된다고 처음엔 착각했었습니다.
인터섹션은 "두 타입을 모두 만족하는 타입"이므로 오히려 프로퍼티가 더 많아집니다.
타입 추론
타입스크립트는 모든 변수에 타입을 명시하지 않아도 됩니다. 초기값을 기준으로 타입을 추론하기 때문입니다.
let a = 10; // number로 추론
let b = "hello"; // string으로 추론
let arr = [1, "hello"]; // (number | string)[]으로 추론
함수의 반환값도 마찬가지입니다.
function func() {
return "hello"; // 반환 타입 string으로 추론
}
한 가지 주의할 동작이 있습니다. const로 선언하면 리터럴 타입으로 추론됩니다.
let num = 10; // number
const num2 = 10; // 10 (리터럴 타입)
let은 재할당이 가능하니 넓게 추론하고, const는 값이 바뀌지 않으니 그 값 자체를 타입으로 확정합니다.
이 차이는 이후 서로소 유니온 타입에서 tag 값을 리터럴로 쓸 때 다시 연결됩니다.
타입을 생략한 채 선언만 하면 암묵적 any로 시작해 이후 할당에 따라 타입이 바뀝니다.
let d;
d = 10;
d.toFixed(); // number로 추론
d = "hello";
d.toLowerCase(); // string으로 추론
동작하긴 하지만 권장하지 않습니다. 타입이 흘러가듯 바뀌는 코드는 추적이 어렵고, 타입스크립트를 쓰는 의미가 희석됩니다.
타입 단언과 타입 좁히기
타입 단언
타입 단언은 개발자가 "이 값은 이 타입이다"라고 직접 선언하는 문법입니다
as 키워드를 사용합니다.
type Person = { name: string; age: number };
let person = {} as Person;
person.name = "yun";
person.age = 21;
빈 객체를 Person으로 단언했기 때문에 컴파일러가 오류를 발생시키지 않습니다. 초기화 시점에 값을 채울 수 없는 경우에 유용합니다.
단언에는 규칙이 있습니다. A as B 형태에서, A가 B의 슈퍼타입이거나 서브타입이어야 합니다.
서로 겹치는 부분이 없는 타입 간에는 단언이 불가능합니다.
let num1 = 10 as never; // number는 never의 슈퍼타입 — 가능
let num2 = 10 as unknown; // number는 unknown의 서브타입 — 가능
let num3 = 10 as string; // 관계 없음 — 오류
이 규칙을 우회하는 방법도 있습니다.
let num4 = 10 as unknown as string; // 다중 단언 — 가능하지만 권장하지 않음
다중 단언은 타입 시스템 자체를 우회하는 방식이라 가능은 하지만, 굳이 사용해도 된다는 의미는 아닙니다.
const 단언은 객체를 불변(readonly)으로 만들 때 유용합니다.
let cat = {
name: "nabi",
color: "pink",
} as const;
cat.name = "momo"; // 오류 — readonly로 변환됨
Non-null 단언(!)은 값이 null이나 undefine가 아님을 보장할 때 씁니다.
type Post = { title: string; author?: string };
let post: Post = { title: "글", author: "yun" };
const len: number = post.author!.length; // !로 undefined 가능성 제거
타입 단언이 공통적으로 가진 특성이 있습니다. 값 자체를 바꾸는 게 아니라 컴파일러의 눈을 속이는 것입니다.
런타임에서 실제 값이 단언한 타입과 다르다면 오류는 그대로 발생합니다. 그래서 단언은 개발자가 타입을 확실히 알고 있을때만 제한적으로 사용해야합니다.
타입 좁히기
유니온 타입처럼 여러 타입이 가능한 상황에서, 조건문을 통해 특정 타입으로 범위를 좁히는 것을 타입 좁히기라고 합니다.
function func(value: number | string | Date | null | Person) {
if (typeof value === "number") {
console.log(value.toFixed());
} else if (typeof value === "string") {
console.log(value.toUpperCase());
}
}
typeof는 원시 타입을 구분할 때 사용합니다. 그런데 객실 타입을 구분할 때는 한계가 있습니다.
typeof null이 "object"를 반환하기 때문에, Date 타입을 typeof로 좁히면 null도 통과해버립니다.
// typeof value === "object" 로 하면 null도 포함됨
else if (value instanceof Date) { // instanceof 사용
console.log(value.getTime());
}
instanceof는 특정 클래스의 인스턴스인지 확인합니다. null은 통과하지 못하기 때문에 객체 타입 좁히기에 적합합니다.
단, 클래스가 아닌 타입 별칭(type)은 instanceof에 사용할 수 없습니다. 타입은 컴파일 후 사라지지만 클래스는 런타임에도 존재하기 때문입니다.
특정 프로퍼티가 있는지 확인할 때는 in 연산자를 사용합니다.
else if (value && "age" in value) {
console.log(value.name);
}
value &&를 앞에 붙인 이유는 null에 in 연산자를 쓰면 런타임 오류가 발생하기 때문입니다.
조건문 분기 하나하나가 타입을 확정하는 근거가 되고, 각 분기 안에서 타입스크립트는 해당 타입으로 값을 추론합니다.
서로소 유니온 타입
타입을 구분하기 어려운 유니온
유니온 타입으로 여러 객체 타입을 묶을 때, 각 타입을 구분하는 방법이 문제가 됩니다.
가장 직관적인 방법은 특정 프로퍼티의 존재 여부로 확인하는 것입니다.
type Admin = { name: string; kickCount: number };
type Member = { name: string; point: number };
type Guest = { name: string; visitCount: number };
type User = Admin | Member | Guest;
function login(user: User) {
if ("kickCount" in user) {
console.log(`${user.name}님 ${user.kickCount}명 강퇴`);
} else if ("point" in user) {
console.log(`${user.name}님 ${user.point}포인트`);
} else {
console.log(`${user.name}님 ${user.visitCount}번 방문`);
}
}
이렇게 구현을 해도 동작은 합니다. 다만 이 코드를 처음보는 사람들은 "kickCount" in user가 무엇을 의미하는지 파악하기 위해 타입 정의를 찾아봐야 합니다. 그리고 세 타입이 name이라는 공통 프로퍼티를 가지고 있어 완전한 서로소 관계도 아닙니다.
교집합 없애기
각 타입에 리터럴 타입의 tag 프로퍼티를 추가하면 문제가 깔끔하게 해결됩니다.
type Admin = { name: string; kickCount: number; tag: "ADMIN" };
type Member = { name: string; point: number; tag: "MEMBER" };
type Guest = { name: string; visitCount: number; tag: "GUEST" };
type User = Admin | Member | Guest;
function login(user: User) {
switch (user.tag) {
case "ADMIN":
console.log(`${user.name}님 ${user.kickCount}명 강퇴`);
break;
case "MEMBER":
console.log(`${user.name}님 ${user.point}포인트`);
break;
case "GUEST":
console.log(`${user.name}님 ${user.visitCount}번 방문`);
}
}
tag가 리터럴 타입이기 때문에 세 타입은 완전한 서로소 관계가 됩니다.
"ADMIN"이면서 동시에 "MEMBER"인 값은 존재할 수 없습니다.
타입스크립트는 switch 각 case 안에서 해당 타입으로 완벽하게 좁혀주고, 코드를 읽는 사람도 tag 하나만 보고 의도를 파악할 수 있습니다.
타입스크립트 타입시스템에 대해서 정리해보니, 처음에 각각의 독립적인 규칙처럼 보이던 것들이 사실상 하나의 맥락에서 나온 것임을 확인할 수 있었습니다. 업캐스팅은 되고 다운캐스팅은 안된다는 계층의 원칙이, 타입 단언의 규칙을 설명하고, 객체 호환성의 기준을 설명하고, 타입 좁히기가 왜 필요한지 알 수 있었습니다.
타입 시스템을 암기의 대상으로 접근하면 예외처럼 보이는 규칙들이 많습니다. 계층 구조를 기준으로 접근하면 대부분 동작이 납득 가능한 결론으로 이어집니다.
'프론트엔드' 카테고리의 다른 글
| 타입 시스템을 구조화하는 세 가지 도구(인터페이스, 클래스, 제네릭) (1) | 2026.02.26 |
|---|---|
| TypeScript 함수와 타입 (0) | 2026.02.22 |
| TypeScript의 기본 타입 (0) | 2026.02.11 |
| TypeScript 컴파일러 옵션 설정 가이드 (0) | 2026.02.10 |
| Cypress 기초 문법 및 설치 방법 (1) | 2025.05.26 |