.d.ts 파일 작성법
구현 없이 타입만 담는 선언 파일에 declare로 전역 변수와 함수, 클래스, 네임스페이스, 모듈의 모양을 적는 방법을 익히고, 선언과 실제 값이 따로 준비되어야 한다는 점을 Node.js 실습으로 확인한 뒤, 선언 파일이 프로젝트에 들어오는 길과 배포하는 흐름을 정리합니다.
타입스크립트 프로젝트에서 자바스크립트로 만든 라이브러리나 예전 코드를 쓰면, 컴파일러는 그 코드가 무엇을 내보내는지 알 수 없습니다. 이때 그 코드의 모양을 타입으로 알려 주는 것이 선언 파일(declaration file)이며, 확장자는 .d.ts입니다. 선언 파일에는 실행할 코드가 없고, 이름과 타입만 들어 있습니다.
이 글은 declare로 여러 종류의 값을 선언하는 방법을 보고, 선언과 실제 값이 서로 따로 준비되어야 한다는 점을 실습으로 확인합니다. 마지막으로 선언 파일이 프로젝트에 들어오는 길과, 라이브러리가 선언 파일을 함께 배포하는 흐름을 정리합니다.
TYPE / RUNTIME / CONTRACT
declare는 기존 값을 설명하고 구현은 따로 제공한다
선언 파일은 코드가 사용하는 이름과 타입을 알려 줍니다. 변수·함수·클래스·enum의 실제 값과 동작은 JavaScript나 외부 실행 환경이 제공해야 합니다.
위는 선언을 읽는 타입 검사 경로, 아래는 실제 값을 제공하는 실행 경로입니다. 두 경로 사이에 값을 생성한다는 간선은 없습니다.
GLOBAL
MY_GLOBAL_CONFIG는 만들어지지 않는다
apiUrl:string과 timeout:number는 사용 코드의 타입 약속입니다. 실제 전역 객체가 로드되지 않으면 해당 이름을 읽는 실행 코드가 실패할 수 있습니다.
FUNCTION / CLASS
호출과 생성의 표면을 설명한다
customLog와 calculateSum, MyEventEmitter<T>의 on/emit은 구현을 포함하지 않습니다. new로 사용할 클래스도 실제 생성자가 필요합니다.
ENUM / NAMESPACE
선언한 객체도 실제 객체가 필요하다
StatusCode.Success=200 등의 ambient enum은 실행 객체를 만들지 않습니다. jQuery.ajax나 $도 라이브러리가 실제로 제공해야 합니다.
MODULE / ASSET
import 대상과 로더를 함께 맞춘다
my-custom-module의 Client와 getData는 실제 패키지 구현이 필요합니다. *.png의 string URL과 *.svg의 URL·React 컴포넌트도 해당 로더가 제공해야 합니다.
선언과 실제 API가 어긋나면 사용 코드가 타입 검사를 통과해도 실행이 실패할 수 있습니다. 선언의 타입 인자는 외부 데이터의 런타임 검증을 대신하지 않습니다.
위 그림처럼 선언은 컴파일러가 검사에 쓰는 이름과 타입을 알려 줄 뿐, 실제 값을 만들거나 바깥에서 들어온 값을 검사하지 않습니다. 변수와 함수, 클래스의 실제 값과 동작은 자바스크립트 코드나 실행 환경이 따로 제공해야 합니다.
선언 파일이 필요한 곳
선언 파일은 다음과 같은 곳에서 씁니다.
- 자바스크립트로 배포된 라이브러리를 타입과 함께 쓰고 싶을 때
- 이미 있는 자바스크립트 코드를 타입스크립트 프로젝트에 들일 때
- 타입스크립트로 만든 라이브러리를 자바스크립트로 출력해 배포하면서, 사용자에게 타입도 함께 줄 때
- 브라우저나 다른 스크립트가 전역에 만들어 둔 값의 타입을 알릴 때
자바스크립트 파일을 allowJs로 프로젝트에 넣으면 컴파일러가 코드와 JSDoc에서 타입을 어느 정도 추론하기도 하지만, 매개변수처럼 단서가 없는 곳은 알 수 없습니다. 선언 파일은 그런 코드의 공개된 모양을 정확한 타입으로 직접 적는 방법입니다.
declare로 모양 적기
선언 파일의 문법은 .ts와 거의 같지만, 값에는 declare를 붙여 "이 값은 다른 곳에 이미 있다"는 것을 나타냅니다. declare가 붙은 선언은 본문이나 초기값 없이 타입만 가지며, 컴파일한 자바스크립트에 아무것도 남기지 않습니다.
declare const MY_GLOBAL_CONFIG: {
apiUrl: string;
timeout: number;
};
declare let globalCounter: number;
declare function customLog(message: string): void;
declare class MyEventEmitter<T> {
on(eventName: string, listener: (data: T) => void): void;
emit(eventName: string, data: T): void;
}declare const와 declare let은 전역 변수의 타입을, declare function은 전역 함수의 시그니처를, declare class는 클래스의 생성자와 멤버의 타입을 적습니다. let으로 선언한 globalCounter는 코드에서 값을 바꿀 수 있고, const로 선언한 MY_GLOBAL_CONFIG에는 다른 값을 넣을 수 없습니다.
전역 객체 하나에 여러 멤버가 달린 라이브러리는 declare namespace로 적습니다. 다음은 공식 선언 참조에 나오는 형태로, 전역의 myLib 객체에 함수 하나와 변수 하나가 있다는 뜻입니다.
declare namespace myLib {
function makeGreeting(s: string): string;
let numberOfGreetings: number;
}이 선언이 프로젝트에 있으면 myLib.makeGreeting("hello")는 문자열을 돌려주는 호출로 검사됩니다. 7장에서 본 실행되는 네임스페이스와 달리, declare namespace는 출력에 아무 코드도 만들지 않습니다.
declare enum도 쓸 수 있지만 조심해야 합니다. 일반 declare enum은 실행 중에 그 열거형 객체가 다른 곳에 정말 있어야 멤버를 읽을 수 있고, declare const enum은 값을 코드에 직접 써넣는 대신 다른 빌드 도구와 함께 쓰기 어려운 제약이 있습니다. 라이브러리가 실제로 어떤 값을 제공하는지 확인한 뒤 고릅니다.
선언과 실제 값을 따로 준비하기
선언만으로는 실행되지 않는다는 점을 Node.js에서 확인해 봅니다. 새 폴더 ts-declarations를 만들고 package.json을 { "type": "commonjs" }로 둔 뒤, 앞의 custom-globals.d.ts를 src에 저장하고 다음 파일을 더합니다.
globalThis.MY_GLOBAL_CONFIG = { apiUrl: "/api", timeout: 1000 };
globalThis.globalCounter = 0;console.log(MY_GLOBAL_CONFIG.apiUrl);
console.log(MY_GLOBAL_CONFIG.timeout);
globalCounter += 1;
console.log(globalCounter);{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noEmitOnError": true
},
"files": ["src/custom-globals.d.ts", "src/demo.ts"]
}tsc -p .
node --require ./src/runtime.js dist/demo.js/api
1000
1--require는 지정한 파일을 먼저 실행한 뒤 본 파일을 실행하라는 Node.js의 옵션입니다. runtime.js가 전역 객체에 실제 값을 만들고, 컴파일러는 custom-globals.d.ts의 선언으로 demo.ts가 그 이름을 올바르게 쓰는지 검사했습니다. node dist/demo.js만 실행하면 값을 만든 코드가 없으므로, 첫 줄에서 ReferenceError: MY_GLOBAL_CONFIG is not defined로 멈춥니다.
선언은 실제 값의 모양을 믿을 뿐 확인하지 않습니다. runtime.js가 timeout을 실수로 문자열 "1000"으로 만들어도 검사는 그대로 통과하고, demo.ts는 그 값을 숫자로 알고 씁니다. 그래서 선언 파일은 실제 구현이 바뀔 때 함께 고쳐야 하고, 설정처럼 바깥에서 들어오는 값은 읽어 들이는 곳에서 따로 검증합니다.
모듈의 선언
전역이 아니라 import로 가져오는 자바스크립트 모듈의 타입은 declare module "이름"으로 적습니다. 블록 안에는 그 모듈이 내보내는 것을 export로 나열합니다.
declare module "my-custom-module" {
export interface Options {
debug: boolean;
port: number;
}
export function init(options: Options): void;
export const version: string;
}이 선언이 프로젝트에 있으면 import { init, version } from "my-custom-module";이 검사를 통과합니다. 하지만 실행하려면 같은 이름으로 그 내보내기들을 실제로 제공하는 모듈이 설치되어 있어야 하며, 선언은 모듈을 만들어 주지 않습니다.
번들러를 쓰는 프로젝트에서는 이미지나 스타일시트처럼 자바스크립트가 아닌 파일도 import합니다. 이런 파일에는 *를 쓴 와일드카드 모듈 선언을 둡니다.
declare module "*.png" {
const src: string;
export default src;
}import logoUrl from "./logo.png";는 이 선언 덕분에 logoUrl이 문자열인 것으로 검사됩니다. 실제로 이미지를 URL 문자열로 바꾸어 주는 것은 번들러의 설정이므로, 선언과 번들러의 처리가 같은 모양을 약속하는지 함께 확인합니다. 이런 선언은 앰비언트 모듈 선언에서 자세히 다룹니다.
선언 파일을 쓸 때의 규칙
선언 파일의 최상위에 import나 export가 하나라도 있으면, 그 파일은 모듈이 되어 그 안의 선언이 전역에 보이지 않습니다. 전역 값을 선언하려는 파일이라면 최상위에서 export를 쓰지 않고, 모듈의 타입을 적으려는 파일이라면 declare module 블록이나 최상위 export를 씁니다.
선언 파일은 공개된 모양만 적습니다. 라이브러리 안에서만 쓰는 함수는 선언할 필요가 없고, 선언한 타입은 any를 피해 가능한 한 정확하게 적어야 사용하는 쪽이 실수를 잡을 수 있습니다.
타입스크립트로 만든 코드라면 선언 파일을 손으로 쓸 필요가 없습니다. declaration 옵션을 켜고 컴파일하면 .js와 함께 .d.ts가 만들어지므로, 생성된 선언을 고쳐야 한다면 원래의 .ts 코드를 고친 뒤 다시 생성합니다. 널리 쓰이는 자바스크립트 라이브러리는 DefinitelyTyped 프로젝트가 @types/ 이름의 패키지로 선언을 제공하므로, 직접 쓰기 전에 먼저 찾아봅니다.
더 깊이: 선언 파일의 검사를 건너뛰는 skipLibCheck
선언 파일도 기본적으로는 다른 코드처럼 타입 검사를 받습니다. 그런데 Vite의 React 템플릿을 비롯한 많은 프로젝트 설정에는 "skipLibCheck": true가 들어 있습니다. TSConfig 레퍼런스는 이 옵션을 선언 파일의 타입 검사를 건너뛰는 설정으로 설명하며, 컴파일 시간을 아끼는 대신 타입 시스템의 정확성을 내준다고 적습니다. 이 옵션을 켜면 모든 .d.ts를 빠짐없이 검사하는 대신, 소스 코드가 실제로 참조하는 부분만 검사합니다.
이름에는 라이브러리(lib)가 들어 있지만 건너뛰는 대상은 선언 파일 전체이므로, 직접 손으로 쓴 .d.ts의 실수도 이 옵션 아래에서는 그 파일 안에서 진단되지 않을 수 있습니다. TSConfig 레퍼런스는 이 옵션을 떠올리게 되는 흔한 상황으로, node_modules에 같은 라이브러리의 타입이 두 벌 들어와 서로 부딪히는 경우를 듭니다. 다만 그때는 이 옵션으로 덮기보다 의존성을 정리해 한 벌만 남기는 방법을 먼저 검토하라고 권합니다. 타입스크립트의 버전을 올리는 동안 node_modules나 표준 라이브러리의 선언에서 생긴 오류를 잠시 미뤄 두는 데에도 쓰입니다.
선언 파일이 프로젝트에 들어오는 길
선언 파일은 아무 폴더에나 두었다고 저절로 읽히지 않습니다. 컴파일러가 선언 파일을 읽는 주된 길은 세 가지입니다.
첫째는 tsconfig.json의 files나 include로 직접 포함하는 것이며, 앞의 실습이 이 방법을 썼습니다. 둘째는 import의 경로를 해석하다가 찾는 것으로, 패키지라면 package.json의 exports나 types 필드가 가리키는 선언 파일을 읽습니다. 셋째는 types 옵션에 적은 @types 패키지를 전역으로 포함하는 것인데, TypeScript 6.0에서 types의 기본값은 빈 배열이므로 필요한 패키지를 직접 적어야 합니다. 이 밖에 파일 맨 위에 /// <reference path="..." />나 /// <reference types="node" />처럼 슬래시 세 개로 시작하는 지시문(triple-slash directive)을 적어, 그 선언을 함께 컴파일하게 할 수도 있습니다.
{
"name": "my-cool-lib",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts"
}TYPE / RUNTIME / CONTRACT
소스의 공개 API를 실행 파일과 선언 파일로 배포한다
TypeScript 소스는 JavaScript 구현과 .d.ts 타입 설명을 출력할 수 있습니다. 패키지는 두 파일의 경로를 각각 연결하며 소비자는 타입 검사와 실행을 따로 확인합니다.
같은 공개 API에서 구현과 선언을 각각 출력합니다. 분기는 동시에 준비할 두 결과를 뜻하며 .js가 자동으로 .d.ts의 정확성을 입증한다는 순서가 아닙니다. 패키지 경로와 소비자 확인은 아래 설명에서 이어집니다.
1 · SOURCE
공개 API를 소스에서 정한다
내보낼 함수·클래스·타입을 정의합니다. 생성된 선언을 바꿔야 하면 원본 API를 수정하고 다시 생성합니다.
2 · OUTPUT
구현과 타입 설명을 함께 준비한다
declaration:true는 .d.ts 생성을 켭니다. declarationMap은 선택 옵션입니다. 손으로 작성한 JavaScript용 선언은 구현과 함께 갱신합니다.
3 · PACKAGE
두 경로를 각각 연결한다
본문 my-cool-lib의 main은 dist/index.js, types는 dist/index.d.ts입니다. 실제 배포 파일에 두 경로의 대상이 존재해야 합니다.
4 · CONSUMER
타입과 실행을 따로 확인한다
소비 코드가 선언을 찾고 올바른 호출을 허용하는지 확인합니다. 실제 import와 호출 결과도 확인해야 하며 타입 통과만으로 구현의 동작을 증명할 수 없습니다.
선언 생성과 타입 검사의 성공은 구분합니다. 오류가 있을 때 출력을 막으려면 noEmitOnError 등 빌드 설정도 확인합니다.
위 그림처럼 라이브러리는 같은 소스에서 실행할 .js와 타입을 알릴 .d.ts를 함께 출력하고, package.json이 두 파일의 경로를 각각 알려 줍니다. 위 예에서 main은 실행할 파일을, types는 선언 파일을 가리킵니다. 사용하는 쪽은 그 선언으로 타입을 검사하고 그 구현으로 실행하므로, 배포하기 전에 두 경로가 실제로 맞는 파일을 가리키는지 확인합니다. 선언 파일의 여러 형태는 공식 선언 참조에 정리되어 있습니다.
정리
- 선언 파일
.d.ts는 실행 코드 없이 이름과 타입만 담아, 자바스크립트 코드와 전역 값의 모양을 컴파일러에게 알립니다. declare가 붙은 선언은 다른 곳에 이미 있는 값을 설명할 뿐 출력에 아무것도 남기지 않으므로, 실제 값은 따로 준비해야 합니다.- 전역 값은
declare const,declare function,declare class,declare namespace로, 가져오는 모듈은declare module "이름"으로 적습니다. - 선언 파일의 최상위에
import나export가 있으면 모듈이 되어 그 안의 선언이 전역에 보이지 않습니다. - 선언 파일은
files와include, 모듈의 해석,types옵션으로 프로젝트에 들어오며, 라이브러리는package.json으로 구현과 선언의 경로를 함께 알립니다.
연습 문제
1. 바뀐 실제 값
runtime.js에서 timeout을 2500으로, globalCounter의 시작 값을 5로 바꾸면 출력의 둘째와 셋째 줄은 무엇이 되나요? 선언 파일은 고쳐야 하나요?
해설 보기
둘째 줄은 2500, 셋째 줄은 5에 1을 더한 6입니다. 두 값 모두 선언한 타입인 number이므로 선언 파일은 고칠 필요가 없습니다. 선언은 값의 종류만 알리고, 값 자체는 실행할 때 runtime.js가 정하기 때문입니다.
2. export를 더한 전역 선언
custom-globals.d.ts의 끝에 export {};를 더하고 다시 컴파일하면 어떻게 되나요?
해설 보기
export가 생기면 그 선언 파일은 모듈이 되어, 그 안의 MY_GLOBAL_CONFIG와 globalCounter가 전역에 보이지 않습니다. 그래서 demo.ts에서 Cannot find name 'MY_GLOBAL_CONFIG'.가 나옵니다. 모듈로 만든 선언 파일에서 전역을 선언하려면 declare global { ... } 블록 안에 적어야 하며, 이 방법은 앰비언트 모듈 선언에서 봅니다.
3. 설치하지 않은 모듈
my-custom-module.d.ts의 선언만 두고 실제 패키지는 설치하지 않은 채, import { version } from "my-custom-module";을 쓴 파일을 컴파일하고 실행하면 각각 어떻게 되나요?
해설 보기
컴파일은 선언 덕분에 통과하고 version은 문자열로 검사됩니다. 하지만 실행하면 Node.js가 my-custom-module 패키지를 찾지 못해 모듈을 찾을 수 없다는 오류로 멈춥니다. 선언은 모듈이 있다고 컴파일러에게 알릴 뿐이므로, 선언을 더할 때는 그 모듈이 실제로 설치되고 같은 이름을 내보내는지도 함께 확인합니다.
다음 글 타입 선언 파일 작성 및 사용에서는 실제 자바스크립트 유틸리티 파일 하나에 선언 파일을 붙여, 컴파일러가 그 선언을 찾는 과정과 함께 써 봅니다.