타입 시스템의 깊은 세계로
조건부 타입 (Conditional Type) 매핑된 타입 (Mapped Type) 템플릿 리터럴 타입 (Template Literal Type) 타입 레벨 프로그래밍
타입 레벨(타입을 다루는 차원)의 삼항 연산자: T extends U ? X : Y
// 기본 문법: "T가 string에 해당하면 true, 아니면 false"
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (리터럴도 string을 확장)
// 중첩 조건부 타입
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
type T3 = TypeName<string[]>; // "object"
// 실전: 배열이면 요소 타입 추출, 아니면 그대로
// infer U = "U라는 타입을 여기서 추출(추론)해라"
type Flatten<T> = T extends Array<infer U> ? U : T;
type Str = Flatten<string[]>; // string
type Num = Flatten<number>; // number (배열이 아니므로 그대로)
조건부 타입이 실제로 어떻게 평가되는지 한 단계씩 따라가기
// 정의
type Flatten<T> = T extends Array<infer U> ? U : T;
// Step 1: T = string[]
// Step 2: string[] extends Array 꺾쇠 infer U 꺾쇠 ? U : string[]
// Step 3: string[]은 Array 꺾쇠 string 꺾쇠 이므로 → 조건 참!
// Step 4: infer U = string (U에 string이 추출됨)
// Step 5: 결과 = U = string ✓
type Result1 = Flatten<string[]>; // string
// Step 1: T = number
// Step 2: number extends Array 꺾쇠 infer U 꺾쇠 ? U : number
// Step 3: number는 Array가 아님 → 조건 거짓!
// Step 4: 결과 = T = number ✓
type Result2 = Flatten<number>; // number
// Step 1: T = number[][]
// Step 2: number[][] extends Array 꺾쇠 infer U 꺾쇠 ?
// Step 3: number[][]는 Array 꺾쇠 number[] 꺾쇠 이므로 → 조건 참!
// Step 4: infer U = number[] (한 겹만 벗겨짐)
// Step 5: 결과 = number[] (완전히 평탄화되지 않음!)
type Result3 = Flatten<number[][]>; // number[]
조건부 타입은 extends로 패턴 매칭하고, infer로 원하는 부분을 추출합니다. 중첩 구조를 완전히 풀려면 재귀 타입이 필요합니다.
infer 키워드 (타입 추론/추출)조건부 타입 안에서 타입을 추출(추론)하는 키워드. infer R은 "여기에 해당하는 타입을 R이라고 부르겠다"는 뜻입니다.
// 함수의 반환 타입 추출 (ReturnType의 내부 구현 원리)
// "T가 함수라면, 그 반환 타입을 R로 추출해라"
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => string>; // string
type R2 = MyReturnType<(x: number) => boolean>; // boolean
type R3 = MyReturnType<string>; // never
// 함수의 매개변수 타입 추출 (Parameters의 내부 구현 원리)
// "T가 함수라면, 그 매개변수 타입들을 P로 추출해라"
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type P1 = MyParameters<(a: string, b: number) => void>;
// [a: string, b: number]
// Promise 내부 타입 추출
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type V1 = UnwrapPromise<Promise<string>>; // string
type V2 = UnwrapPromise<Promise<number[]>>; // number[]
type V3 = UnwrapPromise<string>; // string (Promise가 아님)
// 중첩 Promise도 재귀적으로 풀기 (자기 자신을 다시 호출)
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type V4 = DeepUnwrap<Promise<Promise<Promise<number>>>>; // number
infer 활용 패턴
// 배열의 첫 번째 요소 타입 추출
// [infer F, ...any[]] = "첫 번째를 F로, 나머지는 아무거나"
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type F1 = First<[string, number, boolean]>; // string
type F2 = First<[42, "hello"]>; // 42
type F3 = First<[]>; // never
// 배열의 마지막 요소 타입 추출
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;
type L1 = Last<[string, number, boolean]>; // boolean
// 생성자의 인스턴스 타입 추출
type InstanceOf<T> = T extends new (...args: any[]) => infer I ? I : never;
class UserModel {
constructor(public name: string) {}
}
type UserInstance = InstanceOf<typeof UserModel>; // UserModel
// 문자열에서 패턴 매칭: URL 경로의 :파라미터 이름 추출
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
유니온 타입이 조건부 타입에 들어가면 각 멤버에 하나씩 분배되어 적용됩니다. 마치 "한 명씩 줄 서서 처리"하는 것과 같습니다.
type ToArray<T> = T extends any ? T[] : never;
// 유니온이 분배됨!
type Result = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]
// (string | number)[]가 아님에 주의!
// Exclude 구현 원리 (분배 활용)
// "T의 각 멤버를 하나씩 확인해서, U에 해당하면 제거(never)"
type MyExclude<T, U> = T extends U ? never : T;
type Ex1 = MyExclude<"a" | "b" | "c", "a">;
// = ("a" extends "a" ? never : "a")
// | ("b" extends "a" ? never : "b")
// | ("c" extends "a" ? never : "c")
// = never | "b" | "c"
// = "b" | "c"
// Extract 구현 원리
type MyExtract<T, U> = T extends U ? T : never;
type Ex2 = MyExtract<string | number | boolean, string | boolean>;
// = string | boolean
type NoDistribute<T> = [T] extends [any] ? T[] : never;NoDistribute<string | number> = (string | number)[]
기존 타입의 각 프로퍼티를 변환하여 새 타입을 생성
// 기본 문법: { [K in keyof T]: 새로운_타입 }
// "T의 각 키(K)에 대해 새로운 타입으로 변환하라"
interface User {
id: number;
name: string;
email: string;
age: number;
}
// 모든 프로퍼티를 getter 함수로 변환
// as 절로 키 이름을 "getName", "getEmail" 등으로 변환
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// getAge: () => number;
// }
// 수정자 제어: +/- readonly, +/- ?
type Mutable<T> = {
-readonly [K in keyof T]: T[K]; // readonly 제거
};
type Concrete<T> = {
[K in keyof T]-?: T[K]; // 선택적(?) 제거
};
type ReadonlyUser = { readonly name: string; readonly age: number };
type MutableUser = Mutable<ReadonlyUser>;
// { name: string; age: number } <-- readonly 제거됨!
매핑된 타입과 조건부 타입을 조합하여 실전 유틸리티 타입을 구현하기
// 1. Nullable: 모든 속성에 null을 허용
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User { id: number; name: string; email: string; }
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
// 2. Mutable: readonly 속성을 모두 수정 가능하게
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type FrozenUser = { readonly id: number; readonly name: string };
type EditableUser = Mutable<FrozenUser>;
// { id: number; name: string } ← readonly 제거됨!
// 3. Optional: 특정 키만 선택적으로 만들기
// Pick으로 선택한 키를 Partial로 만들고, 나머지는 Omit으로 유지
type Optional<T, K extends keyof T> =
Omit<T, K> & Partial<Pick<T, K>>;
interface CreateUserDTO {
name: string;
email: string;
age: number;
bio: string;
}
// age와 bio만 선택적으로!
type FlexibleUser = Optional<CreateUserDTO, "age" | "bio">;
// { name: string; email: string; age?: number; bio?: string }
커스텀 유틸리티 타입은 기존 내장 타입(Partial, Pick, Omit 등)을 조합하여 만듭니다. 복잡한 타입도 작은 단위로 분해하면 이해하기 쉽습니다.
// 특정 타입의 프로퍼티만 선택
// "값이 ValueType에 해당하는 키만 남기고, 나머지는 never로 제거"
type PickByType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
interface Model {
id: number;
name: string;
active: boolean;
email: string;
count: number;
}
type StringProps = PickByType<Model, string>;
// { name: string; email: string }
type NumberProps = PickByType<Model, number>;
// { id: number; count: number }
// 이벤트 핸들러 맵 자동 생성
type EventMap<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]:
(newValue: T[K], oldValue: T[K]) => void;
};
interface FormState {
username: string;
password: string;
rememberMe: boolean;
}
type FormEvents = EventMap<FormState>;
// {
// onUsernameChange: (newValue: string, oldValue: string) => void;
// onPasswordChange: (newValue: string, oldValue: string) => void;
// onRememberMeChange: (newValue: boolean, oldValue: boolean) => void;
// }
문자열 리터럴 타입을 조합하여 새로운 문자열 타입 생성. JavaScript의 템플릿 리터럴(`Hello ${name}`)을 타입 세계에 가져온 것입니다.
// 기본 사용
type Greeting = `Hello, ${string}!`;
const g1: Greeting = "Hello, World!"; // OK
// const g2: Greeting = "Hi, World!"; // Error!
// 유니온과 조합 -> 모든 조합이 자동 생성
type Color = "red" | "green" | "blue";
type Size = "sm" | "md" | "lg";
type ClassName = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg"
// | "green-sm" | "green-md" | "green-lg"
// | "blue-sm" | "blue-md" | "blue-lg"
// 총 9개의 조합!
// 내장 문자열 변환 유틸리티
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
// CSS 프로퍼티 타입 생성 예제
type CSSProperty = "margin" | "padding" | "border";
type Direction = "top" | "right" | "bottom" | "left";
type CSSDirectionalProp = `${CSSProperty}-${Direction}`;
// "margin-top" | "margin-right" | ... | "border-left"
// 총 12개!
// 타입 안전한 이벤트 시스템
type EventName<T> = {
[K in keyof T & string]: `${K}Changed`
}[keyof T & string];
interface UserState {
name: string;
age: number;
active: boolean;
}
type UserEvents = EventName<UserState>;
// "nameChanged" | "ageChanged" | "activeChanged"
// 점 표기법(dot notation) 경로 타입: 중첩 객체의 경로를 문자열로 표현
type PathOf<T, Prefix extends string = ""> = {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | PathOf<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string];
interface Config {
db: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
cache: {
ttl: number;
};
}
type ConfigPath = PathOf<Config>;
// "db" | "db.host" | "db.port" | "db.credentials"
// | "db.credentials.user" | "db.credentials.password"
// | "cache" | "cache.ttl"
as 절로 매핑된 타입의 키(프로퍼티 이름)를 변환
// 기본 키 재매핑
type PrefixKeys<T, P extends string> = {
[K in keyof T as `${P}_${string & K}`]: T[K];
};
interface API {
getUser: () => User;
getPost: () => Post;
}
type PrefixedAPI = PrefixKeys<API, "api">;
// { api_getUser: () => User; api_getPost: () => Post }
// 키 필터링: as 절에서 never를 반환하면 해당 키가 제거됨
type RemoveReadonly<T> = {
[K in keyof T as K extends `readonly_${string}` ? never : K]: T[K];
};
// 특정 타입의 프로퍼티만 제거 (값이 U 타입이면 never로 키 삭제)
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
name: string;
age: number;
active: boolean;
email: string;
}
type WithoutStrings = OmitByType<Mixed, string>;
// { age: number; active: boolean }
type WithoutNumbers = OmitByType<Mixed, number>;
// { name: string; active: boolean; email: string }
// ReturnType: 함수의 반환 타입 추출
function createUser(name: string, age: number) {
return { id: Math.random(), name, age, createdAt: new Date() };
}
type User = ReturnType<typeof createUser>;
// { id: number; name: string; age: number; createdAt: Date }
// Parameters: 함수 매개변수 타입을 튜플로 추출
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, age: number]
// ConstructorParameters: 생성자 매개변수 추출
class HttpClient {
constructor(baseUrl: string, timeout: number, headers?: Record<string, string>) {}
}
type HttpClientArgs = ConstructorParameters<typeof HttpClient>;
// [baseUrl: string, timeout: number, headers?: Record<string, string>]
// Awaited: Promise를 재귀적으로 풀기 (TS 4.5+)
type A1 = Awaited<Promise<string>>; // string
type A2 = Awaited<Promise<Promise<number>>>; // number
type A3 = Awaited<string | Promise<number>>; // string | number
// ThisParameterType / OmitThisParameter
function greet(this: { name: string }, greeting: string) {
return `${greeting}, ${this.name}!`;
}
type ThisType = ThisParameterType<typeof greet>; // { name: string }
type WithoutThis = OmitThisParameter<typeof greet>; // (greeting: string) => string
| 유틸리티 타입 | 설명 | 입력 / 출력 |
|---|---|---|
ReturnType<F> |
함수 반환 타입 | () => string → string |
Parameters<F> |
함수 매개변수 튜플 | (a: string) => void → [string] |
ConstructorParameters<C> |
생성자 매개변수 튜플 | 클래스의 constructor 매개변수 |
InstanceType<C> |
클래스 인스턴스 타입 | typeof MyClass → MyClass |
Awaited<T> |
Promise 풀기 | Promise<string> → string |
Uppercase<S> |
문자열 대문자 변환 | "hello" → "HELLO" |
Capitalize<S> |
첫 글자 대문자 | "hello" → "Hello" |
자기 자신을 참조하는 타입으로 트리 구조 등을 표현. "폴더 안에 폴더가 있는" 것처럼 중첩되는 구조를 타입으로 표현할 수 있습니다.
// JSON 값 타입 (재귀적 정의)
// JSONValue 안에 JSONValue가 또 들어갈 수 있음 (자기 자신 참조!)
type JSONValue =
| string // 문자열
| number // 숫자
| boolean // 참/거짓
| null // 없음
| JSONValue[] // JSON 값의 배열 (재귀!)
| { [key: string]: JSONValue }; // JSON 값의 객체 (재귀!)
const data: JSONValue = {
name: "홍길동",
age: 30,
hobbies: ["코딩", "독서"],
address: {
city: "서울",
zip: "12345",
coordinates: [37.5665, 126.9780]
}
};
// 파일 시스템 트리
interface FileNode {
name: string;
type: "file";
size: number;
}
interface FolderNode {
name: string;
type: "folder";
children: TreeNode[]; // 재귀!
}
type TreeNode = FileNode | FolderNode;
const project: TreeNode = {
name: "src", type: "folder",
children: [
{ name: "index.ts", type: "file", size: 1024 },
{ name: "utils", type: "folder", children: [
{ name: "helper.ts", type: "file", size: 512 }
]}
]
};
// 깊은 읽기전용 (Deep Readonly)
// 일반 Readonly는 1단계만 적용되지만, 이것은 모든 중첩 레벨에 적용
type DeepReadonly<T> =
T extends (infer U)[] // 배열이면?
? ReadonlyArray<DeepReadonly<U>> // 읽기전용 배열로 + 요소도 재귀
: T extends object // 객체면?
? { readonly [K in keyof T]: DeepReadonly<T[K]> } // 각 프로퍼티에 재귀 적용
: T; // 원시 타입이면 그대로
interface Config {
db: { host: string; ports: number[] };
features: { dark: boolean };
}
type FrozenConfig = DeepReadonly<Config>;
// {
// readonly db: {
// readonly host: string;
// readonly ports: ReadonlyArray<number>
// };
// readonly features: { readonly dark: boolean };
// }
// 깊은 Partial (Deep Partial)
type DeepPartial<T> = T extends object ? {
[K in keyof T]?: DeepPartial<T[K]>;
} : T;
type PartialConfig = DeepPartial<Config>;
// db?, db.host?, db.ports?, features?, features.dark?
// 모든 중첩 프로퍼티가 선택적으로!
// 설정 병합에 유용
function mergeConfig(
defaults: Config,
overrides: DeepPartial<Config>
): Config {
// 깊은 병합 로직...
return { ...defaults, ...overrides } as Config;
}
구조적 타입 시스템에서 명목적 타입(이름이 다르면 다른 타입)을 흉내내는 기법
// 문제: 구조가 같으면 같은 타입으로 취급됨
type UserId = number; // 사용자 ID
type PostId = number; // 게시글 ID (둘 다 그냥 number!)
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId: UserId = 1;
const postId: PostId = 2;
getUser(postId); // 에러 없음! 하지만 논리적 버그!
// 해결: 브랜디드 타입으로 구별
// 보이지 않는 "브랜드 태그"를 붙여서 타입을 구분
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, "UserId">;
type PostId = Brand<number, "PostId">;
// 생성 함수 (팩토리)
function createUserId(id: number): UserId {
return id as UserId;
}
function createPostId(id: number): PostId {
return id as PostId;
}
function getUser(id: UserId) { /* ... */ }
const userId = createUserId(1);
const postId = createPostId(2);
getUser(userId); // OK!
// getUser(postId); // Error! PostId는 UserId에 할당 불가!
// getUser(42); // Error! number는 UserId에 할당 불가!
// 유효성이 검증된 값을 타입으로 보장하는 실전 패턴
type Brand<T, B extends string> = T & { readonly __brand: B };
type Email = Brand<string, "Email">; // 검증된 이메일
type PositiveNumber = Brand<number, "PositiveNumber">; // 양수만
type NonEmptyString = Brand<string, "NonEmptyString">; // 비어있지 않은 문자열
// 검증 함수 = 브랜드를 부여하는 함수 (검증 통과해야만 타입 획득)
function validateEmail(input: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input)) {
throw new Error(`유효하지 않은 이메일: ${input}`);
}
return input as Email;
}
function toPositive(n: number): PositiveNumber {
if (n <= 0) throw new Error("양수여야 합니다");
return n as PositiveNumber;
}
// 검증된 값만 받는 함수
function sendEmail(to: Email, subject: string) {
// to는 이미 검증된 이메일 주소!
console.log(`메일 발송: ${to}`);
}
const email = validateEmail("user@example.com"); // Email
sendEmail(email, "안녕하세요"); // OK!
// sendEmail("not-validated", "테스트"); // Error!
"검증되지 않은 값"과 "검증된 값"을 타입 레벨에서 구분하여
런타임 (Runtime, 실행 시점) 에러를 컴파일 타임 (Compile Time, 코드 변환 시점)에 방지합니다.
타입 시스템 자체를 프로그래밍 언어처럼 활용하기. 값이 아닌 타입을 대상으로 조건문, 반복, 재귀 등을 수행합니다. 실행 시점(런타임)이 아닌 코드 작성 시점(컴파일 타임)에 모든 계산이 이루어집니다.
// 타입 레벨 문자열 조작: camelCase -> kebab-case 변환
// 대문자를 만나면 앞에 "-"를 붙이고 소문자로 변환
type CamelToKebab<S extends string> =
S extends `${infer Head}${infer Tail}`
? Head extends Uppercase<Head>
? `-${Lowercase<Head>}${CamelToKebab<Tail>}`
: `${Head}${CamelToKebab<Tail>}`
: S;
type K1 = CamelToKebab<"backgroundColor">; // "background-color"
type K2 = CamelToKebab<"fontSize">; // "font-size"
type K3 = CamelToKebab<"borderTopWidth">; // "border-top-width"
// 타입 레벨 덧셈 (튜플 길이를 이용한 트릭)
// 길이가 N인 튜플(배열)을 만들어서 길이로 숫자를 표현
type BuildTuple<N extends number, T extends any[] = []> =
T["length"] extends N ? T : BuildTuple<N, [...T, any]>;
// 두 튜플을 합치면 길이가 합산됨 = 덧셈!
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]["length"];
type Sum = Add<3, 4>; // 7 (타입 레벨에서 계산!)
// 타입 레벨 배열 뒤집기 (재귀 활용)
type Reverse<T extends any[]> =
T extends [infer First, ...infer Rest]
? [...Reverse<Rest>, First] // 나머지를 뒤집고 첫 번째를 맨 뒤에
: [];
type Rev = Reverse<[1, 2, 3, 4]>; // [4, 3, 2, 1]
T extends U ? X : Y - 타입 레벨 분기[K in keyof T]로 프로퍼티 일괄 변환as로 키 이름 변환 및 필터링