고차 타입(Higher-Kinded Types) — TS에서 흉내내는 법
Higher-Kinded Type(HKT)은 타입 생성자 자체를 매개변수로 받는 것입니다. Haskell에서는 기본 문법이지만, TypeScript에서는 직접 지원하지 않아 패턴으로 흉내 냅니다.
문제: 왜 HKT가 필요한가
// 배열을 매핑하는 함수
function mapArray<A, B>(arr: A[], fn: (a: A) => B): B[] {
return arr.map(fn);
}
// Promise를 매핑하는 함수
async function mapPromise<A, B>(p: Promise<A>, fn: (a: A) => B): Promise<B> {
return fn(await p);
}
// 패턴이 동일함! "컨테이너<A>를 받아서 컨테이너<B>를 반환"
// 하지만 "컨테이너"를 추상화할 수 없음
// ❌ 이런 건 불가능
// function map<F, A, B>(container: F<A>, fn: (a: A) => B): F<B>
// F<A> — TypeScript는 타입 생성자를 매개변수로 받을 수 없음
패턴 1: 인터페이스 매핑 (Lightweight HKT)
// 타입 레지스트리
interface TypeRegistry {
Array: unknown[];
Promise: Promise<unknown>;
Option: unknown | null;
}
// Kind 유틸리티 — 타입 생성자를 문자열 키로 참조
type Kind<F extends keyof TypeRegistry, A> =
F extends 'Array' ? A[] :
F extends 'Promise' ? Promise<A> :
F extends 'Option' ? A | null :
never;
// 이제 "컨테이너"를 추상화할 수 있음
interface Functor<F extends keyof TypeRegistry> {
map<A, B>(fa: Kind<F, A>, fn: (a: A) => B): Kind<F, B>;
}
// Array Functor 구현
const arrayFunctor: Functor<'Array'> = {
map: (fa, fn) => fa.map(fn),
};
// Promise Functor 구현
const promiseFunctor: Functor<'Promise'> = {
map: (fa, fn) => fa.then(fn),
};
패턴 2: URItoKind 매핑
fp-ts 라이브러리에서 사용하는 패턴입니다.
// URI 상수
const ArrayURI = 'Array' as const;
type ArrayURI = typeof ArrayURI;
const OptionURI = 'Option' as const;
type OptionURI = typeof OptionURI;
// URI → 타입 매핑 인터페이스 (선언 병합으로 확장 가능)
interface URItoKind<A> {
Array: A[];
Option: A | null;
}
// HKT 유틸리티
type URIS = keyof URItoKind<any>;
type Kind<F extends URIS, A> = URItoKind<A>[F];
// Functor 인터페이스
interface Functor<F extends URIS> {
readonly URI: F;
map: <A, B>(fa: Kind<F, A>, fn: (a: A) => B) => Kind<F, B>;
}
// 구현
const arrayFunctor: Functor<'Array'> = {
URI: 'Array',
map: (fa, fn) => fa.map(fn),
};
선언 병합으로 확장
// 새로운 타입 생성자를 추가할 때
interface URItoKind<A> {
Tree: TreeNode<A>;
}
type TreeNode<A> = {
value: A;
children: TreeNode<A>[];
};
// Tree Functor 구현
const treeFunctor: Functor<'Tree'> = {
URI: 'Tree',
map: (fa, fn) => ({
value: fn(fa.value),
children: fa.children.map((child) => treeFunctor.map(child, fn)),
}),
};
패턴 3: 제네릭 인터페이스 활용
더 간단한 접근으로, 제네릭 인터페이스를 직접 넘기는 패턴입니다.
// "매핑 가능한" 인터페이스
interface Mappable<T> {
map<U>(fn: (value: T) => U): Mappable<U>;
}
// 구현
class Box<T> implements Mappable<T> {
constructor(public value: T) {}
map<U>(fn: (value: T) => U): Box<U> {
return new Box(fn(this.value));
}
}
// 사용
const box = new Box(42);
const result = box.map((n) => n.toString()); // Box<string>
HKT를 쓰는 라이브러리
| 라이브러리 | 패턴 | 설명 |
|---|---|---|
| fp-ts | URItoKind | TypeScript 함수형 프로그래밍 |
| Effect | 제네릭 서비스 | 타입 안전한 의존성 주입 |
| purify-ts | 클래스 기반 | Maybe, Either, IO 등 |
정리
- HKT는 타입 생성자를 매개변수로 받는 것으로, TypeScript에서 직접 지원하지 않는다
- 문자열 키 + 인터페이스 매핑으로 HKT를 흉내낼 수 있다
- 선언 병합으로 새 타입 생성자를 확장적으로 추가할 수 있다
- fp-ts 등 함수형 라이브러리에서 이 패턴을 적극적으로 사용한다
- 실무에서 직접 구현할 일은 드물지만, 라이브러리의 타입 이해에 도움이 된다
댓글 로딩 중...