TypeScript 선언 병합: declare module로 라이브러리 타입 확장하기
타입스크립트로 개발하다 보면 라이브러리가 제공하는 타입이 살짝 아쉬운 순간이 찾아옵니다.
@types 패키지까지 설치했는데도 process.env.DATABASE_URL에 자동 완성이 안 뜬다거나, 익스프레스 요청 객체(req)에 인증 미들웨어가 붙여둔 req.user를 꺼내려는데 “그런 속성 없다”며 빨간 줄이 그어지는 경우 말이죠. 🤔
그렇다고 node_modules 안에 있는 라이브러리의 .d.ts 파일을 직접 고칠 수도 없는 노릇입니다.
패키지를 다시 설치하면 수정 사항이 그대로 날아가 버리니까요.
이럴 때 쓰는 게 바로 선언 병합과 declare module입니다.
라이브러리 코드는 한 줄도 건드리지 않고, 내 프로젝트에서 타입만 살짝 덧붙이거나 채워 넣을 수 있죠.
이번 포스팅에서는 이 두 기능이 어떻게 맞물려 동작하는지, 그리고 실무에서 자주 마주치는 상황에 어떻게 적용하는지 차근차근 알아보겠습니다.
참고로 @types로 시작하는 타입 선언 패키지가 무엇이고 왜 필요한지 궁금하시다면, Definitely Typed: TypeScript의 타입 정의 저장소를 먼저 읽고 오시면 이해가 훨씬 수월합니다.
선언 병합이란?
타입스크립트에는 조금 독특한 규칙이 하나 있는데요.
같은 이름의 interface를 여러 번 선언하면 에러가 나는 게 아니라, 타입스크립트가 알아서 하나로 합쳐 줍니다.
이걸 선언 병합(Declaration Merging)이라고 부릅니다.
interface Box {
width: number;
height: number;
}
interface Box {
scale: number;
}
// 두 선언이 하나로 합쳐집니다.
const box: Box = { width: 100, height: 50, scale: 2 };
Box를 두 번 선언했지만 타입스크립트는 이를 { width: number; height: number; scale: number }라는 하나의 인터페이스로 취급합니다.
세 속성 중 하나라도 빠뜨리면 바로 타입 에러가 나죠.
처음 보면 “이게 무슨 쓸모가 있나” 싶을 수 있는데요. 바로 이 성질 덕분에 내가 직접 만들지 않은 라이브러리의 인터페이스에도 속성을 끼워 넣을 수 있습니다. 라이브러리가 선언한 인터페이스와 같은 이름으로 인터페이스를 한 번 더 선언하면, 타입스크립트가 둘을 병합해 주니까요.
interface는 열려 있고 type은 닫혀 있다
여기서 한 가지 짚고 넘어갈 게 있습니다.
선언 병합은 interface에서만 동작하고 type에서는 동작하지 않는다는 점인데요.
같은 이름으로 type을 두 번 선언하면 병합은커녕 곧장 에러가 발생합니다.
type Point = { x: number };
type Point = { y: number }; // ❌ Duplicate identifier 'Point'.ts(2300)
그래서 흔히 “interface는 열려 있고(open), type은 닫혀 있다(closed)“고 표현합니다.
평소에 interface와 type 중 무엇을 쓸지 고민될 때, 바로 이 차이가 중요한 판단 기준이 됩니다.
나중에 외부에서 확장하거나 보강할 여지를 남겨 두고 싶은 공개 API라면 interface가 유리하고, 그럴 일이 없는 내부 전용 타입이라면 type을 써도 무방하죠.
라이브러리 타입을 확장하는 모든 기법의 바탕에는 이 “interface는 병합된다”는 규칙이 깔려 있습니다. 그럼 이제 본격적으로 라이브러리 타입을 건드려 볼까요?
declare module로 라이브러리 타입 보강하기
라이브러리가 자신의 타입을 모듈로 내보내는 경우, declare module "라이브러리명" 구문으로 그 안의 인터페이스를 보강할 수 있습니다.
이를 모듈 보강(module augmentation)이라고 합니다.
예를 들어 my-lib라는 라이브러리가 다음과 같은 Config 인터페이스를 내보낸다고 해보죠.
declare module "my-lib" {
export interface Config {
name: string;
}
export function load(): Config;
}
여기에 우리만의 debug 속성을 더하고 싶다면, 프로젝트에 .d.ts 파일을 하나 만들고 같은 모듈 이름으로 Config를 다시 선언하면 됩니다.
import "my-lib";
declare module "my-lib" {
interface Config {
debug: boolean;
}
}
이제 load()가 돌려주는 Config 객체에서 name은 물론 debug까지 자동 완성과 타입 검사가 모두 동작합니다.
import { load } from "my-lib";
const config = load();
config.name; // string ✅ (라이브러리 원본)
config.debug; // boolean ✅ (우리가 보강한 속성)
여기서 맨 윗줄의 import "my-lib";가 의외로 중요한데요.
declare module로 기존 모듈을 보강하려면 그 파일이 “모듈”로 인식되어야 합니다.
파일 안에 import나 export가 하나도 없으면 타입스크립트는 그 파일을 전역 스크립트로 취급해서, 특히 일반 .ts 파일에서는 보강이 의도대로 동작하지 않을 수 있습니다.
그러니 보강할 라이브러리를 import하거나, 마땅히 가져올 게 없다면 파일 맨 위에 export {}; 한 줄을 넣어 모듈임을 명시해 주는 습관을 들이는 게 안전합니다.
타입이 아예 없는 패키지에 직접 선언하기
지금까지는 라이브러리가 이미 제공하는 타입에 살을 붙이는 경우였는데요.
반대로 타입 선언이 통째로 없는 오래된 패키지를 만날 때도 있습니다.
@types조차 없는 패키지를 import하면 타입스크립트가 “선언 파일을 찾을 수 없다”며 에러를 냅니다.
이럴 때도 declare module을 쓰는데, 이번엔 보강이 아니라 없는 타입을 처음부터 선언하는 용도입니다.
가장 간단한 방법은 모듈 이름만 적어 두는 것인데요.
declare module "untyped-pkg";
이렇게만 해두면 해당 모듈에서 가져온 값이 전부 any 타입이 됩니다.
타입 검사를 완전히 포기하는 셈이라 마음이 편하진 않지만, 일단 빌드는 통과시킬 수 있죠.
조금 더 욕심을 내서, 우리가 실제로 쓰는 API에만 최소한의 타입을 직접 달아 줄 수도 있습니다.
declare module "legacy-slugify" {
export default function slugify(input: string): string;
}
이제 slugify()에 문자열이 아닌 값을 넘기면 타입스크립트가 곧바로 막아 줍니다.
import slugify from "legacy-slugify";
const slug = slugify("Hello World"); // string ✅
slugify(123); // ❌ Argument of type 'number' is not assignable to parameter of type 'string'.ts(2345)
같은 declare module이지만 이미 타입이 있는 모듈에 쓰면 “보강”이 되고, 타입이 없는 모듈에 쓰면 “선언”이 된다는 점이 재미있죠.
참고로 이렇게 손수 만든 타입이 쓸 만하다면 Definitely Typed에 기여해서 다른 개발자들과 공유할 수도 있습니다.
declare global로 전역 타입 보강하기
모든 라이브러리가 타입을 모듈로 내보내는 건 아닙니다.
process.env처럼 어디서든 import 없이 쓸 수 있는 전역(global) 타입도 있죠.
이런 전역 타입은 declare global 블록 안에서 보강합니다.
가장 흔한 예가 환경 변수입니다.
기본적으로 process.env의 속성은 전부 string | undefined 타입인데요.
그래서 다음 코드는 타입 에러가 납니다.
const url: string = process.env.DATABASE_URL;
// ❌ Type 'string | undefined' is not assignable to type 'string'.ts(2322)
process.env의 타입은 @types/node가 NodeJS.ProcessEnv라는 전역 인터페이스로 정의해 두었습니다.
이 인터페이스를 보강하면 우리 프로젝트에서 쓰는 환경 변수의 이름과 타입을 명시할 수 있죠.
export {};
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
PORT: string;
}
}
}
이렇게 해두면 process.env.DATABASE_URL이 string으로 좁혀져서 위의 타입 에러가 사라지고, 환경 변수 이름까지 자동 완성됩니다.
오타로 엉뚱한 변수를 참조하는 실수도 줄어들죠.
다만 한 가지 주의할 점이 있습니다.
여기서 string이라고 선언하는 건 “이 환경 변수는 항상 존재한다”고 타입스크립트에게 약속하는 것일 뿐, 런타임에 실제로 값이 들어 있는지까지 검사해 주지는 않습니다.
값이 비어 있으면 undefined가 그대로 흘러들어 갈 수 있으니, 정말 필수적인 환경 변수라면 앱이 시작할 때 별도로 검증하는 편이 안전합니다.
실전: 익스프레스 요청 객체에 속성 더하기
전역 보강이 가장 빛을 발하는 순간이 바로 익스프레스(Express)입니다.
인증 미들웨어를 만들 때 요청 객체에 req.user를 붙여 두는 패턴을 많이 쓰는데요.
정작 타입스크립트는 Request에 그런 속성이 없다며 에러를 냅니다.
app.get("/me", (req, res) => {
const email = req.user?.email;
// ❌ Property 'user' does not exist on type 'Request<...>'.ts(2339)
res.send(email ?? "anonymous");
});
@types/express는 Request 타입을 전역 Express 네임스페이스 아래에 두고 있어서, 다음과 같이 declare global로 보강할 수 있습니다.
export {};
declare global {
namespace Express {
interface Request {
user?: { id: string; email: string };
}
}
}
이제 미들웨어와 라우트 핸들러 어디서든 req.user를 타입 안전하게 꺼내 쓸 수 있습니다.
더 이상 (req as any).user 같은 꼼수를 쓰지 않아도 되죠. 😎
라이브러리가 일부러 비워둔 인터페이스 채우기
지금까지는 우리가 라이브러리 타입에 “끼어드는” 경우였는데요. 반대로 라이브러리가 처음부터 “여기를 채워 주세요” 하고 빈 인터페이스를 열어 두는 설계도 있습니다.
대표적인 예가 TanStack Router입니다.
TanStack Router는 비어 있는 Register 인터페이스를 내보내고, 사용자가 declare module로 자신의 라우터 타입을 여기에 등록하도록 설계되어 있죠.
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
이 한 조각을 등록해 두면 라이브러리가 Register에서 라우터 타입을 끄집어내, 앱 전체에서 경로와 검색 파라미터를 타입 검사해 줍니다.
<Link to="/abuot">처럼 경로를 잘못 쓰면 곧바로 빨간 줄이 그어지죠.
선언 병합을 활용해 “타입 차원의 의존성 주입”을 구현한 셈입니다.
GraphQL 클라이언트인 Apollo Client도 똑같은 방식을 씁니다.
Apollo Client는 TypeOverrides라는 빈 인터페이스를 열어 두는데요, declare module "@apollo/client"로 이걸 보강하면 내장 유틸리티 타입의 동작을 우리 프로젝트에 맞게 바꿀 수 있습니다.
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "modern";
}
}
재미있는 건 Apollo Client가 이 TypeOverrides에 넘기는 값으로 higher-kinded types(HKT)라는 기법까지 활용한다는 점입니다.
아직 평가하지 않은 제네릭 타입을 미리 등록해 두고 나중에 라이브러리 내부에서 알맞게 채워 쓰도록 하는 고급 기법인데요, 이것만으로도 글 한 편 분량이라 여기서는 이름만 짚고 넘어가겠습니다.
이 패턴은 이렇게 타입 안전성을 중시하는 최신 라이브러리 곳곳에서 발견됩니다.
라이브러리가 빈 interface를 노출하고 있다면, 십중팔구 declare module로 채워 주길 기대하는 자리라고 보시면 됩니다.
타입을 “바꾸는” 것은 안 된다
여기까지 읽으셨다면 “그럼 라이브러리의 잘못된 타입을 내 마음대로 고칠 수도 있겠네?”라고 기대하실 수 있는데요. 아쉽게도 선언 병합은 속성을 추가할 수 있을 뿐, 이미 있는 멤버의 타입을 다른 타입으로 바꾸지는 못합니다.
예를 들어 앞서 본 Config의 name: string을 name: number로 바꿔 보강하려고 하면 다음과 같은 에러가 납니다.
declare module "my-lib" {
interface Config {
name: number;
// ❌ Subsequent property declarations must have the same type.
// Property 'name' must be of type 'string', but here has type 'number'.ts(2717)
}
}
병합은 어디까지나 “더하기”라서, 기존 멤버와 충돌하는 선언은 거부당합니다.
그렇다면 라이브러리 타입이 명백히 틀렸을 때는 어떻게 해야 할까요?
정말로 기존 타입을 갈아끼우고 싶다면 선언 병합만으로는 부족하고, skipLibCheck로 타입 검사를 우회하거나 patch-package로 .d.ts를 직접 수정하는 등 다른 수단을 동원해야 합니다.
흥미롭게도 선언 병합으로 어느 정도 “교정”이 가능한 경우도 있는데요.
함수는 같은 이름으로 선언하면 충돌 대신 오버로드가 추가되기 때문입니다.
타입스크립트 빌트인 타입의 황당한 동작을 바로잡아 주는 ts-reset 라이브러리가 바로 이 원리를 활용합니다.
전역 Array나 Set 같은 인터페이스에 더 합리적인 오버로드를 병합해서, 읽기 전용 배열의 includes()가 엉뚱한 타입 에러를 내지 않도록 손봐 주는 식이죠.
라이브러리를 만든다면
지금까지는 라이브러리를 쓰는 입장이었는데요, 잠깐 입장을 바꿔 라이브러리를 만드는 쪽이라면 어떨까요? 방금 본 보강 기법들은 곧 “사용자가 내 라이브러리 타입에 손댈 수 있다”는 뜻이기도 하니까요.
우선, 사용자가 확장하길 바라는 타입은 type 대신 interface로 내보내세요.
type은 닫혀 있어 사용자가 선언 병합으로 확장할 수 없지만, interface는 열려 있습니다.
한발 더 나아가 TanStack Router의 Register처럼 일부러 빈 interface를 노출해 두면, 사용자가 declare module로 자기 타입을 끼워 넣는 깔끔한 확장 지점이 됩니다.
반대로 조심할 것도 있는데요.
라이브러리 안에서 declare global로 전역 타입을 보강하는 건 되도록 피하세요.
그 라이브러리를 설치한 모든 사용자에게 전역 변경이 강제로 따라붙기 때문입니다.
좋은 의도로 빌트인 타입을 “고쳐” 둬도, 사용자 입장에선 원치 않는 타입 변경이 몰래 끼어든 셈이 되죠.
같은 이유로 ts-reset도 애플리케이션에서만 쓰고 라이브러리에선 쓰지 말라고 권합니다.
타입 확장은 사용자가 자기 프로젝트에서 선택적으로 하도록 열어 두는 게 좋습니다.
참고로 Register 같은 레지스트리 인터페이스를 직접 설계하는 법이나, Apollo Client처럼 HKT로 오버라이드 가능한 타입을 만드는 법은 라이브러리 저자를 위한 확장 가능한 TypeScript 타입 설계에서 따로 다룹니다.
마치며
지금까지 선언 병합과 declare module로 라이브러리 타입을 확장하는 방법을, 또 라이브러리를 만드는 입장에서는 무엇을 신경 써야 하는지까지 살펴봤습니다.
핵심만 정리하면 이렇습니다.
interface는 같은 이름으로 여러 번 선언하면 하나로 합쳐지고, 라이브러리가 모듈로 내보낸 타입은 declare module "라이브러리명"으로, process.env나 익스프레스 Request처럼 전역에 있는 타입은 declare global로 보강합니다.
그리고 병합은 속성을 더할 수는 있어도 기존 멤버의 타입을 바꾸지는 못한다는 점도 기억해 두면 좋겠습니다.
라이브러리 코드를 직접 고치지 않고도 내 프로젝트에 꼭 맞는 타입을 빚어낼 수 있다는 건 타입스크립트의 큰 매력 중 하나인데요.
처음엔 (req as any) 같은 단언으로 대충 넘기던 부분도, 보강 파일 하나만 잘 만들어 두면 훨씬 견고하고 친절한 코드가 됩니다.
더 자세한 내용은 TypeScript 공식 문서의 Declaration Merging 페이지를 참고하세요.
This work is licensed under
CC BY 4.0