본문으로 건너뛰기
Advertisement

6.5 재귀 타입

재귀 타입이란?

**재귀 타입(Recursive Type)**은 타입 정의 안에서 자기 자신을 참조하는 타입입니다. 계층형 구조(트리, 중첩 JSON, 파일 시스템 등)를 타입으로 표현할 때 필수적입니다.

// 가장 간단한 재귀 타입
type NestedArray<T> = T | NestedArray<T>[];
// number | number[] | number[][] | ...

TypeScript 3.7부터 재귀 타입 별칭(recursive type alias)을 직접 지원합니다. 이전에는 인터페이스를 통한 우회 방법이 필요했습니다.

재귀 타입을 사용하면:

  • JSON처럼 깊이를 알 수 없는 중첩 구조를 정확하게 표현할 수 있습니다.
  • 파일 시스템, DOM 트리, 메뉴 구조 같은 계층형 데이터를 모델링합니다.
  • DeepPartial, DeepReadonly 같이 모든 깊이에 적용되는 유틸리티 타입을 구현합니다.

핵심 개념

1. 재귀 타입 기본 구조

// 기본 패턴: 종단 조건(base case) + 재귀 케이스
type RecursiveType =
| BaseCase // 종단 조건 — 재귀를 멈추는 구체적 타입
| { nested: RecursiveType }; // 재귀 케이스

// 연결 리스트 타입
type LinkedList<T> =
| null // 빈 리스트 (종단)
| { value: T; next: LinkedList<T> }; // 노드

// 이진 트리 타입
type BinaryTree<T> =
| null // 빈 노드 (종단)
| {
value: T;
left: BinaryTree<T>;
right: BinaryTree<T>;
};

// 사용
const list: LinkedList<number> = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: null,
},
},
};

const tree: BinaryTree<number> = {
value: 10,
left: { value: 5, left: null, right: null },
right: { value: 15, left: null, right: null },
};

2. JSONValue 타입 — 중첩 JSON 표현

// JSON이 표현할 수 있는 모든 값의 타입
type JSONPrimitive = string | number | boolean | null;

type JSONValue =
| JSONPrimitive
| JSONValue[] // 배열 (재귀)
| { [key: string]: JSONValue }; // 객체 (재귀)

// 또는 더 명시적으로
type JSONObject = { [key: string]: JSONValue };
type JSONArray = JSONValue[];

// 사용
const data: JSONValue = {
name: '홍길동',
age: 30,
active: true,
address: {
city: '서울',
zip: '12345',
coordinates: [37.5665, 126.9780],
},
tags: ['개발자', 'TypeScript'],
metadata: null,
};

// JSON 직렬화/역직렬화 타입 안전 래퍼
function safeJsonParse(input: string): JSONValue {
return JSON.parse(input) as JSONValue;
}

function safeJsonStringify(value: JSONValue): string {
return JSON.stringify(value);
}

3. DeepPartial, DeepReadonly, DeepRequired

// DeepPartial: 모든 깊이의 프로퍼티를 optional로
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

// DeepReadonly: 모든 깊이를 읽기 전용으로
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;

// DeepRequired: 모든 깊이의 optional 제거
type DeepRequired<T> = T extends object
? { [K in keyof T]-?: DeepRequired<T[K]> }
: T;

// DeepMutable: 모든 깊이의 readonly 제거
type DeepMutable<T> = T extends (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T;

// 사용 예시
interface AppConfig {
database: {
host: string;
port: number;
options: {
poolSize: number;
timeout: number;
};
};
cache: {
ttl: number;
maxItems: number;
};
}

// 패치 업데이트: 모든 필드가 선택적
type ConfigPatch = DeepPartial<AppConfig>;

const patch: ConfigPatch = {
database: {
options: {
poolSize: 10, // 다른 필드는 생략 가능
},
},
};

// 불변 설정: 모든 필드가 readonly
type ImmutableConfig = DeepReadonly<AppConfig>;

4. 재귀 타입과 조건부 타입 결합

// 재귀적으로 타입을 언랩(unwrap)하는 패턴
type Unwrap<T> = T extends Promise<infer U>
? Unwrap<U>
: T extends Array<infer U>
? Unwrap<U>
: T;

type A = Unwrap<Promise<Promise<number>>>; // number
type B = Unwrap<number[][]>; // number
type C = Unwrap<Promise<string[]>>; // string

// Awaited<T>의 재귀 unwrap 원리 (TypeScript 내장)
// type Awaited<T> = T extends null | undefined
// ? T
// : T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
// ? F extends ((value: infer V, ...args: infer _) => any)
// ? Awaited<V>
// : never
// : T;

// 중첩된 배열을 평탄화하는 타입
type Flatten<T> = T extends Array<infer U>
? Flatten<U>
: T;

type FlatNumber = Flatten<number[][][]>; // number
type FlatString = Flatten<string[]>; // string

5. 중첩 경로 타입 (Nested Path)

// 객체의 모든 중첩 경로를 "a.b.c" 형태로 추출
type PathsOf<T, Prefix extends string = ''> = {
[K in keyof T & string]: T[K] extends object
? Prefix extends ''
? PathsOf<T[K], K> | K
: PathsOf<T[K], `${Prefix}.${K}`> | `${Prefix}.${K}`
: Prefix extends ''
? K
: `${Prefix}.${K}`;
}[keyof T & string];

interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
database: {
url: string;
name: string;
};
}

type ConfigPaths = PathsOf<Config>;
// 'server' | 'server.host' | 'server.port'
// | 'server.ssl' | 'server.ssl.enabled' | 'server.ssl.cert'
// | 'database' | 'database.url' | 'database.name'

// 경로로 값 타입 추출
type ValueAtPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? ValueAtPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;

type ServerHost = ValueAtPath<Config, 'server.host'>; // string
type SSLEnabled = ValueAtPath<Config, 'server.ssl.enabled'>; // boolean

코드 예제

예제 1: 파일 시스템 트리 타입

// 파일 노드
interface FileNode {
type: 'file';
name: string;
size: number;
extension: string;
modifiedAt: Date;
}

// 디렉토리 노드 — 자기 자신을 참조
interface DirectoryNode {
type: 'directory';
name: string;
children: FileSystemNode[]; // 재귀 참조
modifiedAt: Date;
}

type FileSystemNode = FileNode | DirectoryNode;

// 파일 시스템 탐색 함수
function getAllFiles(node: FileSystemNode): FileNode[] {
if (node.type === 'file') {
return [node];
}
return node.children.flatMap(getAllFiles);
}

function getTotalSize(node: FileSystemNode): number {
if (node.type === 'file') {
return node.size;
}
return node.children.reduce((sum, child) => sum + getTotalSize(child), 0);
}

function findByExtension(
node: FileSystemNode,
ext: string
): FileNode[] {
if (node.type === 'file') {
return node.extension === ext ? [node] : [];
}
return node.children.flatMap(child => findByExtension(child, ext));
}

function printTree(node: FileSystemNode, indent = 0): void {
const prefix = ' '.repeat(indent);
if (node.type === 'file') {
console.log(`${prefix}📄 ${node.name} (${node.size}B)`);
} else {
console.log(`${prefix}📁 ${node.name}/`);
node.children.forEach(child => printTree(child, indent + 1));
}
}

// 예시 파일 시스템
const rootFS: FileSystemNode = {
type: 'directory',
name: 'project',
modifiedAt: new Date(),
children: [
{
type: 'directory',
name: 'src',
modifiedAt: new Date(),
children: [
{ type: 'file', name: 'index.ts', size: 1024, extension: 'ts', modifiedAt: new Date() },
{ type: 'file', name: 'utils.ts', size: 512, extension: 'ts', modifiedAt: new Date() },
],
},
{ type: 'file', name: 'package.json', size: 256, extension: 'json', modifiedAt: new Date() },
],
};

const tsFiles = findByExtension(rootFS, 'ts');
console.log(`TypeScript 파일: ${tsFiles.length}`);

예제 2: 중첩 메뉴 구조

interface MenuItem {
id: string;
label: string;
path?: string; // 리프 노드의 경로
icon?: string;
badge?: number;
disabled?: boolean;
children?: MenuItem[]; // 재귀 — 하위 메뉴
}

// 메뉴 유틸리티 함수
function findMenuItemById(
items: MenuItem[],
id: string
): MenuItem | undefined {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findMenuItemById(item.children, id);
if (found) return found;
}
}
return undefined;
}

function flattenMenu(items: MenuItem[]): MenuItem[] {
return items.flatMap(item => [
item,
...(item.children ? flattenMenu(item.children) : []),
]);
}

function getMenuDepth(items: MenuItem[], depth = 0): number {
return Math.max(
depth,
...items.map(item =>
item.children ? getMenuDepth(item.children, depth + 1) : depth
)
);
}

function buildBreadcrumb(
items: MenuItem[],
targetId: string,
path: MenuItem[] = []
): MenuItem[] | null {
for (const item of items) {
const currentPath = [...path, item];
if (item.id === targetId) return currentPath;
if (item.children) {
const found = buildBreadcrumb(item.children, targetId, currentPath);
if (found) return found;
}
}
return null;
}

// 예시 메뉴
const appMenu: MenuItem[] = [
{
id: 'home',
label: '홈',
path: '/',
icon: 'home',
},
{
id: 'users',
label: '사용자 관리',
icon: 'people',
children: [
{ id: 'users-list', label: '사용자 목록', path: '/users' },
{ id: 'users-add', label: '사용자 추가', path: '/users/new' },
{
id: 'users-roles',
label: '역할 관리',
children: [
{ id: 'roles-list', label: '역할 목록', path: '/users/roles' },
{ id: 'roles-add', label: '역할 추가', path: '/users/roles/new' },
],
},
],
},
];

const breadcrumb = buildBreadcrumb(appMenu, 'roles-add');
// [홈, 사용자 관리, 역할 관리, 역할 추가]

예제 3: DOM 트리 타입 모델링

// 최소한의 DOM 트리 타입
interface TextNode {
nodeType: 'text';
content: string;
}

interface ElementNode {
nodeType: 'element';
tagName: string;
attributes: Record<string, string>;
children: DOMNode[]; // 재귀
}

interface CommentNode {
nodeType: 'comment';
content: string;
}

type DOMNode = TextNode | ElementNode | CommentNode;

// DOM 트리 직렬화 (HTML 생성)
function serialize(node: DOMNode, indent = 0): string {
const pad = ' '.repeat(indent);

switch (node.nodeType) {
case 'text':
return `${pad}${node.content}`;

case 'comment':
return `${pad}<!-- ${node.content} -->`;

case 'element': {
const attrs = Object.entries(node.attributes)
.map(([k, v]) => ` ${k}="${v}"`)
.join('');

if (node.children.length === 0) {
return `${pad}<${node.tagName}${attrs} />`;
}

const children = node.children
.map(child => serialize(child, indent + 1))
.join('\n');

return `${pad}<${node.tagName}${attrs}>\n${children}\n${pad}</${node.tagName}>`;
}
}
}

// 사용
const dom: DOMNode = {
nodeType: 'element',
tagName: 'div',
attributes: { class: 'container' },
children: [
{
nodeType: 'element',
tagName: 'h1',
attributes: {},
children: [{ nodeType: 'text', content: '안녕하세요!' }],
},
{ nodeType: 'comment', content: '본문 시작' },
{
nodeType: 'element',
tagName: 'p',
attributes: { class: 'body' },
children: [{ nodeType: 'text', content: 'TypeScript 재귀 타입 예제입니다.' }],
},
],
};

console.log(serialize(dom));

실전 예제

실전 1: 상태 관리 — 중첩 상태 업데이트

// 중첩 경로로 값을 업데이트하는 재귀 유틸리티
type SetValueByPath<T, P extends string, V> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? { [K in keyof T]: K extends Key ? SetValueByPath<T[K], Rest, V> : T[K] }
: T
: P extends keyof T
? { [K in keyof T]: K extends P ? V : T[K] }
: T;

// 런타임 구현
function setNestedValue<T extends object>(
obj: T,
path: string,
value: unknown
): T {
const keys = path.split('.');
if (keys.length === 1) {
return { ...obj, [keys[0]]: value };
}
const [first, ...rest] = keys;
return {
...obj,
[first]: setNestedValue(
(obj as any)[first] ?? {},
rest.join('.'),
value
),
};
}

// 중첩 객체에서 값 가져오기
function getNestedValue<T>(obj: T, path: string): unknown {
return path.split('.').reduce(
(current: unknown, key) =>
current != null && typeof current === 'object'
? (current as any)[key]
: undefined,
obj
);
}

// 사용
interface State {
user: {
profile: {
name: string;
avatar: string;
};
preferences: {
theme: 'light' | 'dark';
language: string;
};
};
}

const state: State = {
user: {
profile: { name: '홍길동', avatar: '/images/default.png' },
preferences: { theme: 'light', language: 'ko' },
},
};

const newState = setNestedValue(state, 'user.preferences.theme', 'dark');
const theme = getNestedValue(newState, 'user.preferences.theme'); // 'dark'

실전 2: 스키마 검증 시스템

// 재귀적 스키마 타입 — Zod/Yup 스타일
type StringSchema = { type: 'string'; minLength?: number; maxLength?: number; pattern?: RegExp };
type NumberSchema = { type: 'number'; min?: number; max?: number; integer?: boolean };
type BooleanSchema = { type: 'boolean' };
type ArraySchema = { type: 'array'; items: Schema; minItems?: number; maxItems?: number };
type ObjectSchema = { type: 'object'; properties: { [key: string]: Schema }; required?: string[] };
type UnionSchema = { type: 'union'; schemas: Schema[] };

// 재귀 유니온
type Schema =
| StringSchema
| NumberSchema
| BooleanSchema
| ArraySchema
| ObjectSchema
| UnionSchema;

// 스키마에서 TypeScript 타입 추론
type InferSchema<S extends Schema> =
S extends StringSchema ? string :
S extends NumberSchema ? number :
S extends BooleanSchema ? boolean :
S extends ArraySchema ? InferSchema<S['items']>[] :
S extends ObjectSchema ? {
[K in keyof S['properties']]: InferSchema<S['properties'][K]>
} :
S extends UnionSchema ? InferSchema<S['schemas'][number]> :
never;

// 스키마 정의
const userSchema = {
type: 'object' as const,
properties: {
id: { type: 'number' as const },
name: { type: 'string' as const, minLength: 1, maxLength: 50 },
email: { type: 'string' as const, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
roles: {
type: 'array' as const,
items: { type: 'string' as const },
},
},
required: ['id', 'name', 'email'],
};

type InferredUser = InferSchema<typeof userSchema>;
// {
// id: number;
// name: string;
// email: string;
// roles: string[];
// }

고수 팁

팁 1: 재귀 타입 깊이 제한

// TypeScript 재귀 타입 한계: 기본적으로 약 100 수준까지
// 너무 깊으면 "Type instantiation is excessively deep" 오류 발생

// 깊이를 제한하는 패턴: 카운터 배열 사용
type BuildTuple<L extends number, T extends unknown[] = []> =
T['length'] extends L ? T : BuildTuple<L, [...T, unknown]>;

type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10][T];

// 최대 깊이 제한이 있는 DeepPartial
type DeepPartialLimited<T, Depth extends number = 5> =
[Depth] extends [0]
? T
: T extends object
? { [K in keyof T]?: DeepPartialLimited<T[K], Prev<Depth>> }
: T;

// 깊이 5까지만 재귀 (기본값)
type LimitedPartial = DeepPartialLimited<{
a: { b: { c: { d: { e: { f: string } } } } }
}>;

팁 2: 테일 재귀 최적화 스타일

TypeScript 4.5+에서 조건부 타입의 테일 재귀 최적화(Tail Recursion Elimination)를 지원합니다.

// 테일 재귀 최적화 패턴: 결과를 누적 파라미터로 전달
// 일반 재귀 (깊이 제한에 걸릴 수 있음)
type Reverse1<T extends unknown[]> =
T extends [infer Head, ...infer Tail]
? [...Reverse1<Tail>, Head]
: [];

// 테일 재귀 최적화 (더 깊은 재귀 처리 가능)
type Reverse2<T extends unknown[], Acc extends unknown[] = []> =
T extends [infer Head, ...infer Tail]
? Reverse2<Tail, [Head, ...Acc]> // 결과를 Acc에 누적
: Acc;

type R1 = Reverse1<[1, 2, 3, 4, 5]>; // [5, 4, 3, 2, 1]
type R2 = Reverse2<[1, 2, 3, 4, 5]>; // [5, 4, 3, 2, 1]

// 실용적 예: 튜플에서 특정 타입 제거
type RemoveType<
T extends unknown[],
U,
Acc extends unknown[] = []
> = T extends [infer Head, ...infer Tail]
? Head extends U
? RemoveType<Tail, U, Acc> // 매칭되면 건너뜀
: RemoveType<Tail, U, [...Acc, Head]> // 아니면 누적
: Acc;

type WithoutStrings = RemoveType<[1, 'a', 2, 'b', 3], string>;
// [1, 2, 3]

팁 3: 재귀 타입과 인터페이스의 차이

// 인터페이스는 암묵적 지연 평가(lazy evaluation)로 재귀 지원
// 따라서 복잡한 재귀에는 인터페이스가 더 안정적일 수 있음

// 방법 1: 타입 별칭 (TypeScript 3.7+에서 대부분 가능)
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};

// 방법 2: 인터페이스 (더 이전 버전에서도 안정적)
interface ITreeNode<T> {
value: T;
children: ITreeNode<T>[];
}

// 방법 3: 중간 인터페이스를 사용한 우회
interface RecursiveObject {
[key: string]: JSONValueWithInterface;
}
interface RecursiveArray extends Array<JSONValueWithInterface> {}
type JSONValueWithInterface =
| string | number | boolean | null
| RecursiveObject
| RecursiveArray;

팁 4: 재귀 타입 성능 최적화

// 재귀 타입이 느려지는 상황과 해결책

// 문제: 같은 타입을 여러 번 재귀적으로 처리
type SlowDeepMerge<T, U> = {
[K in keyof T | keyof U]:
K extends keyof T & keyof U
? T[K] extends object
? U[K] extends object
? SlowDeepMerge<T[K], U[K]> // 두 번 체크
: U[K]
: U[K]
: K extends keyof T
? T[K]
: K extends keyof U
? U[K]
: never;
};

// 해결: 조건부 타입 결과를 캐시 (infer 활용)
type FastDeepMerge<T, U> = {
[K in keyof T | keyof U]: K extends keyof T & keyof U
? [T[K], U[K]] extends [object, object]
? FastDeepMerge<T[K], U[K]>
: U[K]
: K extends keyof T
? T[K]
: K extends keyof U
? U[K]
: never;
} extends infer R ? { [K in keyof R]: R[K] } : never; // 전개해서 캐시

정리 표

개념설명예시
재귀 타입 기본타입이 자기 자신을 참조LinkedList<T>, TreeNode<T>
JSONValue모든 JSON 값의 재귀 타입string | number | JSONValue[]
DeepPartial모든 깊이 optional 처리{ [K in keyof T]?: DeepPartial<T[K]> }
DeepReadonly모든 깊이 readonly 처리재귀 + readonly 수식어
중첩 경로 타입"a.b.c" 형태의 경로 추출PathsOf<T>, ValueAtPath<T, P>
Awaited중첩 Promise 재귀 unwrapTypeScript 4.5+ 내장
재귀 깊이 제한카운터 배열로 최대 깊이 제어Prev<N> 패턴
테일 재귀 최적화누적 파라미터로 더 깊은 재귀 지원TypeScript 4.5+
인터페이스 재귀지연 평가로 안정적인 재귀interface I { children: I[] }
Flatten중첩 배열/Promise 평탄화T extends Array<infer U> ? Flatten<U> : T

다음 장에서는 Ch7: 유틸리티 타입 심화로 넘어갑니다. Exclude, Extract, NonNullable, ReturnType, Parameters, ConstructorParameters, InstanceType 등 TypeScript 내장 유틸리티 타입의 내부 구현 원리와 조합 패턴을 학습합니다.

Advertisement