라이브러리 저자를 위한 확장 가능한 TypeScript 타입 설계
TypeScript 선언 병합: declare module로 라이브러리 타입 확장하기에서는 사용자 입장에서 남의 라이브러리 타입을 보강하는 법을 봤는데요.
declare module "@tanstack/react-router"로 Register를 채우거나, declare module "@apollo/client"로 TypeOverrides를 채우는 식이었죠.
그런데 그 Register나 TypeOverrides라는 빈 인터페이스는 대체 누가, 어떻게 만들어 둔 걸까요? 🤔
이번 글은 입장을 완전히 뒤집어서, 라이브러리를 만드는 쪽에서 “사용자가 자기 타입을 끼워 넣을 수 있는 확장 지점”을 어떻게 설계하는지 알아봅니다.
TanStack Router의 Register부터 Apollo Client가 쓰는 higher-kinded types까지, 실제 라이브러리들이 쓰는 기법을 직접 만들어 보겠습니다.
선언 병합과 declare module이 처음이라면 앞선 글을 먼저 읽고 오시는 걸 권합니다. 이 글은 그 위에서 출발하거든요.
빈 인터페이스가 확장 지점이 되는 원리
라이브러리가 사용자에게 “여기에 당신 타입을 등록하세요” 하고 열어 두는 자리는 보통 비어 있는 interface입니다.
interface는 열려 있어서, 사용자가 같은 이름으로 선언 병합을 하면 이 빈 인터페이스가 채워지죠.
문제는 라이브러리가 “사용자가 뭘 등록했는지”를 미리 알 수 없다는 점입니다. 그래서 조건부 타입과 infer 키워드로 “등록돼 있으면 그 타입을, 아니면 기본값을” 꺼내는 패턴을 씁니다.
// 사용자가 채울 빈 레지스트리
interface Register {}
type DefaultRouter = { routes: string };
// Register에 router가 등록돼 있으면 그 타입을, 없으면 기본값을
type RegisteredRouter = Register extends { router: infer R }
? R
: DefaultRouter;
이제 사용자가 자기 라우터 타입을 선언 병합으로 등록하면,
interface Register {
router: { routes: "/" | "/about" };
}
라이브러리 내부의 RegisteredRouter가 사용자가 등록한 타입으로 좁혀집니다.
const r: RegisteredRouter = { routes: "/about" }; // ✅
const bad: RegisteredRouter = { routes: "/nope" };
// ❌ Type '"/nope"' is not assignable to type '"/" | "/about"'.ts(2322)
이게 바로 TanStack Router의 Register 인터페이스가 동작하는 방식입니다.
라이브러리는 빈 인터페이스 하나와 조건부 타입만 준비해 두고, 실제 타입은 사용자가 채워 넣는 “타입 차원의 의존성 주입”인 셈이죠.
평가되지 않은 타입을 넘기고 싶을 때
여기서 한 단계 더 욕심을 내볼까요?
방금은 사용자가 구체적인 타입({ routes: ... })을 등록했는데요.
만약 라이브러리가 “사용자가 정의한 타입 변환 규칙을, 라이브러리 내부의 여러 타입에 적용”하고 싶다면 어떨까요?
예를 들어 “응답 타입에서 __masked 필드를 떼어내는 규칙”을 사용자가 정의하고, 라이브러리는 그 규칙을 자기 유틸리티 타입 곳곳에 적용하는 식입니다.
이건 타입 하나를 넘기는 게 아니라, 제네릭 타입 생성자를 통째로 넘겨야 한다는 뜻인데요.
string이나 User는 그 자체로 완성된 타입이지만, Array나 Box는 타입 인자를 받아야 비로소 타입이 되는 “타입을 만드는 타입”입니다.
바로 이 타입 생성자 자체를 통째로 넘기려는 거죠.
그런데 TypeScript에는 결정적인 제약이 있습니다. 바로 아직 인자를 채우지 않은 제네릭 타입을 값처럼 넘길 수 없다는 점입니다.
type Box<T> = { value: T };
type Registry = {
box: Box; // ❌ Generic type 'Box<T>' requires 1 type argument(s).ts(2314)
};
Box를 인자 없이 그냥 넘기려 하면 곧장 에러가 납니다.
이렇게 타입 생성자 자체를 일급으로 다루는 기능을 higher-kinded types(HKT)라고 부르는데, TypeScript는 이걸 네이티브로 지원하지 않습니다.
HKT 흉내내기
네이티브 지원은 없지만, 커뮤니티가 찾아낸 영리한 우회법이 있습니다. 인터페이스에 “인자가 들어올 자리”와 “결과가 나올 자리”를 미리 뚫어 놓고, 나중에 인자를 채워 결과를 꺼내는 방식이죠.
interface HKT {
arg1: unknown; // 인자가 들어올 자리
return: unknown; // 결과가 나올 자리
}
// HKT를 "적용"한다 = arg1을 채우고 return을 꺼낸다
type Apply<F extends HKT, A> = (F & { arg1: A })["return"];
핵심은 this["arg1"]입니다.
구체적인 타입 생성자를 HKT를 확장해 정의하면서, 결과 자리에서 this["arg1"]로 인자를 참조하는 거죠.
interface ArrayHKT extends HKT {
return: Array<this["arg1"]>;
}
type Result = Apply<ArrayHKT, string>;
// ^? string[]
Apply가 평가되는 과정을 뜯어보면 이렇습니다.
먼저 F & { arg1: A }로 HKT의 arg1 자리를 인자 A로 덮어쓰고, ["return"]으로 결과 자리를 인덱스 접근해 꺼냅니다.
이때 결과 자리에 적어 둔 this["arg1"]이 방금 덮어쓴 A를 가리키게 되죠.
그래서 Apply<ArrayHKT, string>은 arg1이 string으로 채워진 뒤 Array<string>, 즉 string[]으로 평가됩니다.
함수 호출을 타입 수준에서 흉내 낸 셈이고, 드디어 “Array라는 타입 생성자”를 값처럼 넘겨 두고 나중에 적용할 수 있게 된 거죠.
Apollo Client가 @apollo/client/utilities에서 내보내는 HKT 인터페이스가 정확히 이 구조입니다(arg1부터 arg4까지, 그리고 return).
오버라이드 가능한 유틸리티 타입 만들기
이제 빈 인터페이스 레지스트리와 HKT를 합치면, 사용자가 라이브러리의 내장 타입 동작을 바꿔 끼울 수 있는 진짜 확장 지점이 완성됩니다.
Apollo Client의 TypeOverrides가 바로 이 조합인데요.
라이브러리는 우선 오버라이드 레지스트리를 비워 두고, 기본 구현을 HKT로 정의한 다음, “레지스트리에 등록돼 있으면 그걸, 아니면 기본을” 고르는 유틸리티 타입을 공개합니다.
interface HKT {
arg1: unknown;
return: unknown;
}
type Apply<F extends HKT, A> = (F & { arg1: A })["return"];
// 오버라이드 레지스트리 (비워 둠)
interface TypeOverrides {}
// 기본 구현: 입력을 그대로 통과시키는 HKT
interface DefaultMask extends HKT {
return: this["arg1"];
}
// 등록돼 있으면 그 HKT, 없으면 기본 HKT
type Resolve<
K extends string,
Fallback extends HKT,
> = K extends keyof TypeOverrides
? TypeOverrides[K] extends HKT
? TypeOverrides[K]
: Fallback
: Fallback;
// 라이브러리가 공개하는 유틸리티 타입
type Masked<A> = Apply<Resolve<"Mask", DefaultMask>, A>;
사용자가 아무것도 안 하면 Masked<T>는 기본 구현대로 T를 그대로 돌려줍니다.
하지만 사용자가 자기만의 규칙을 HKT로 정의해 TypeOverrides에 등록하면,
interface MyMask extends HKT {
return: Omit<this["arg1"], "__masked">;
}
interface TypeOverrides {
Mask: MyMask;
}
이제 Masked<T>가 사용자 규칙을 따라 __masked 필드를 떼어냅니다.
type Result = Masked<{ id: number; __masked: true }>;
// ^? { id: number }
const ok: Result = { id: 1 }; // ✅
const ng: Result = { id: 1, __masked: true };
// ❌ '__masked' does not exist in type '{ id: number; }'
라이브러리 코드는 한 줄도 안 바뀌었는데 사용자가 내장 유틸리티 타입의 동작을 통째로 바꿔 끼운 거죠.
Apollo Client는 이 방식으로 FragmentType이나 Unmasked 같은 유틸리티 타입을 사용자가 자기 코드 생성 포맷에 맞게 교체할 수 있도록 열어 둡니다.
확장 지점을 설계할 때 지킬 것
이런 확장 지점은 강력한 만큼 책임도 따릅니다.
우선 확장을 의도한 타입은 반드시 interface로 노출하세요.
type은 닫혀 있어 사용자가 선언 병합으로 손댈 수 없습니다.
그리고 확장 지점을 문서화하세요.
비어 있는 Register나 TypeOverrides는 코드만 봐선 “여기를 채우라”는 의도가 드러나지 않습니다.
어떤 키를 어떤 형태로 등록해야 하는지 안내가 없으면 사용자는 손댈 엄두를 못 내죠.
또 기본값을 항상 마련해 두세요.
위 Resolve처럼 사용자가 등록하지 않은 경우의 fallback이 없으면, 등록 전에는 타입이 never로 무너지거나 에러가 납니다.
마지막으로 버전 관리에 주의가 필요한데요.
앞선 글에서 봤듯이 선언 병합은 멤버를 추가할 뿐 기존 타입은 바꾸지 못합니다.
즉 라이브러리가 한번 공개한 멤버의 타입은 사용자가 보강으로 고칠 수 없으니, 처음 설계할 때 신중해야 합니다.
HKT 구현은 TypeScript 내부 동작에 기대는 면이 있어, Apollo Client도 자신들의 HKT 인터페이스를 “마이너 버전 사이에 구현이 바뀔 수 있다”며 베타로 표시해 두었습니다.
마치며
지금까지 라이브러리 저자 입장에서 사용자가 타입을 확장할 수 있는 지점을 설계하는 법을 살펴봤습니다.
빈 interface와 조건부 타입으로 Register식 레지스트리를 만들고, 거기에 HKT를 얹으면 Apollo Client의 TypeOverrides처럼 내장 타입 동작까지 사용자가 바꿔 끼울 수 있게 됩니다.
모두 선언 병합이라는 한 가지 기능 위에 차곡차곡 쌓아 올린 것이고요.
higher-kinded types는 여기서 다룬 것보다 훨씬 깊은 주제라, 처음에는 낯설게 느껴지는 게 당연합니다.
실제 프로덕션에서 이 패턴이 어떻게 쓰이는지 궁금하시다면 Apollo Client의 TypeScript 문서에서 HKT와 TypeOverrides를 다루는 부분을 참고하세요.
This work is licensed under
CC BY 4.0