선언 파일(.d.ts) — 타입 정보의 배포 형식
타입스크립트 생태계를 지탱하는 .d.ts 선언 파일을 해부합니다. declaration 옵션의 출력물, declare 키워드의 의미, 라이브러리가 타입을 배포하는 구조와 직접 작성하는 방법까지 다룹니다.
지난 글에서 모듈 구문이 출력 JavaScript로 변환되는 규칙을 정리했다. 그런데 컴파일 출력에는 JavaScript 말고 한 종류가 더 있다. node_modules를 열어보면 어디에나 있는 .d.ts 선언 파일(declaration file) 이다. npm에서 받은 JavaScript 라이브러리에 자동완성이 동작하는 것, tsc가 외부 패키지의 API 오용을 잡아내는 것 — 전부 이 파일 덕분이다. 이번 글에서 선언 파일이 무엇이고 어떻게 만들어지며 어떻게 읽는지를 정리한다.
컴파일 출력의 나머지 절반
TypeScript 컴파일은 소스에서 두 가지를 분리해 낸다. 런타임에 실행될 로직은 .js로, 타입 정보는 .d.ts로 나간다. tsconfig.json에서 옵션 하나만 켜면 된다.
{
"compilerOptions": {
"declaration": true,
"declarationMap": true // .d.ts → 원본 .ts 점프용 소스맵
}
}
src/math.ts ──tsc──▶ dist/math.js (런타임 코드)
dist/math.d.ts (타입 선언)
dist/math.d.ts.map (선언 소스맵)
declarationMap은 선택이지만 라이브러리라면 켜는 것이 좋다. 사용하는 쪽 에디터에서 “정의로 이동”을 눌렀을 때 .d.ts가 아니라 원본 .ts 소스로 점프하게 해 주기 때문이다.
.d.ts 안에는 무엇이 있나
선언 파일의 내용은 한 마디로 “구현을 뺀 시그니처”다. 직접 비교해 보면 명확하다.
// lib.ts — 원본
export function add(a: number, b: number): number {
return a + b;
}
export const VERSION = "1.2.0";
// lib.d.ts — 생성된 선언
export declare function add(a: number, b: number): number;
export declare const VERSION: "1.2.0";
함수 본문이 사라지고 시그니처에 declare 키워드가 붙었다. declare는 컴파일러에게 보내는 약속이다 — “이 이름의 구현은 다른 곳(짝이 되는 .js)에 실제로 존재한다. 너는 형태만 알면 된다.” 그래서 선언 파일에는 실행 코드를 쓸 수 없다. 함수 본문, 변수 초기화 값 같은 것이 들어가면 컴파일 에러다.
컴파일러는 .d.ts를 어떻게 찾는가
모듈 해석 글에서 본 규칙이 여기서 완성된다. import { add } from "my-lib"을 해석할 때 컴파일러가 타입을 찾는 경로는 다음 순서다.
// node_modules/my-lib/package.json
{
"name": "my-lib",
"main": "./dist/index.js",
"types": "./dist/index.d.ts", // ① 명시적 진입점
"exports": {
".": {
"types": "./dist/index.d.ts", // ② nodenext/bundler가 보는 곳
"default": "./dist/index.js"
}
}
}
types(또는 exports의 types 조건) 필드가 가리키는 파일이 그 패키지 타입의 진입점이 된다. 필드가 없으면 index.d.ts를 관례로 찾고, 그것도 없으면 같은 이름 규칙이 동작한다 — ./dist/index.js 옆에 ./dist/index.d.ts가 있으면 자동으로 짝을 짓는다. 패키지에 타입이 아예 없을 때 @types/* 폴백으로 넘어가는 것은 다음 글의 주제다.
직접 작성하는 경우
.d.ts는 대부분 자동 생성되지만, 직접 손으로 쓰는 상황이 두 가지 있다. 하나는 타입이 없는 사내 JavaScript 모듈에 타입을 입힐 때, 다른 하나는 빌드 산출물 없이 타입만 제공하고 싶을 때다.
// legacy-utils.d.ts — 옆에 있는 legacy-utils.js의 타입을 수작업으로
export declare function slugify(input: string): string;
export interface PageMeta {
title: string;
updatedAt: Date;
}
export declare function parseMeta(raw: string): PageMeta;
작성 요령은 단순하다. 공개 API만, 구현 없이, 최대한 정확한 타입으로. 참고로 인터페이스와 타입 별칭은 원래 타입 공간에만 존재하므로 declare가 필요 없고, 값 공간에 존재하는 함수·변수·클래스에만 declare를 붙인다.
직접 작성한 선언이 실제 구현과 어긋나는 것이 이 방식의 최대 리스크다. 컴파일러는 선언 파일을 신뢰할 뿐 검증하지 않는다. 선언이 거짓말을 하면 타입 검사는 통과하고 런타임에서 터진다. 그래서 손으로 쓴 .d.ts는 가능한 한 빨리 소스를 TypeScript로 전환하거나, 최소한 테스트로 시그니처를 검증하는 것이 안전하다.
정리
선언 파일은 TypeScript 생태계의 유통 화폐다. 라이브러리 저자는 declaration: true로 .js와 .d.ts를 함께 배포하고, 컴파일러는 types 필드와 이름 짝짓기 규칙으로 그것을 찾아내며, 에디터는 그 정보로 자동완성을 그린다. 그렇다면 타입을 직접 배포하지 않는 수만 개의 JavaScript 라이브러리는 어떻게 타입을 갖게 됐을까? 다음 글에서 커뮤니티가 운영하는 거대한 타입 저장소, DefinitelyTyped와 @types를 다룬다.
지난 글: verbatimModuleSyntax — 모듈 구문을 쓴 그대로
다음 글: DefinitelyTyped와 @types — 커뮤니티 타입 생태계
읽어주셔서 감사합니다. 😊