타입스크립트 심화 #6 모듈과 .d.ts
지금까지 다섯 편은 모두 내가 짠 타입을 어떻게 가공하는가 였습니다. 이번 글은 그 너머 — 외부에서 들어오는 타입을 어떻게 받아들이고 확장하는가가 주제입니다.
같은 코드인데도 어떤 모듈은 자동으로 타입이 잡히고, 어떤 라이브러리는 import만 해도 빨간 줄이 뜨는 차이는 어디서 오는지. 글로벌 객체에 새 속성을 더하려면 무엇을 써야 하는지. 이런 질문들의 답이 선언 파일과 declare 키워드에 들어 있습니다.
모듈은 어떻게 타입을 갖게 되나 #
타입스크립트가 모듈의 타입을 알아내는 경로는 셋입니다.
- 직접
.ts로 작성 — 가장 단순. 같은 프로젝트의 코드. - 패키지 안에
.d.ts파일이 동봉 — 라이브러리가 자체 선언 파일을 제공하는 경우. @types/xxx패키지 — DefinitelyTyped가 별도로 제공하는 커뮤니티 선언 파일.
npm install --save-dev @types/lodash요즘은 인기 라이브러리 대부분이 자체적으로 .d.ts를 동봉합니다. @types/...가 필요한 건 점점 줄어드는 추세입니다. 그래도 가끔은 직접 만들어야 할 때가 있습니다.
패키지가 어떻게 타입을 노출하나 — package.json
#
package.json의 types (또는 typings) 필드가 핵심입니다.
{
"name": "my-lib",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}types가 가리키는 파일이 모듈의 공개 타입 인터페이스입니다. import { x } from 'my-lib' 했을 때 타입스크립트가 그 파일을 읽어서 x의 타입을 알아냅니다.
요즘 ESM 패키지는 exports 필드 안에 types를 두는 게 표준이 됐습니다.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}라이브러리 작성자가 아니라면 자세히 알 필요는 없지만, 타입이 안 잡힐 때 package.json부터 보는 게 시작점이라는 건 기억해 두세요.
.d.ts 파일 — 구현 없이 시그니처만
#
확장자 .d.ts는 declaration의 약자입니다. 실행 가능한 코드를 넣지 않고 타입 시그니처만 적는 파일입니다.
declare function greet(name: string): string;
declare const VERSION: string;
declare namespace Utils {
function delay(ms: number): Promise<void>;
}declare 키워드는 “이 값/함수/모듈이 어딘가에 존재한다, 컴파일러는 그 시그니처만 믿고 사용해라” 라는 뜻입니다. 실제 구현은 자바스크립트 쪽에 있다고 약속하는 것입니다.
타입 추론에는 영향이 있지만 컴파일 결과 자바스크립트에는 아무 흔적도 남지 않습니다. 순수하게 타입 정보만 추가하는 파일입니다.
외부 라이브러리에 타입이 없을 때 #
@types도 없고 자체 타입도 없는 패키지를 import 하면 보통 다음 에러가 납니다.
Cannot find module 'some-old-lib' or its corresponding type declarations.해결 방법 두 가지.
1) 빠르게 막기 — 모듈 전체를 any로 선언
#
내 프로젝트의 src/types/global.d.ts 같은 파일에 다음 한 줄을 추가합니다.
declare module 'some-old-lib';이러면 그 모듈의 모든 export가 any가 됩니다. 빠르게 막을 수 있지만 그 라이브러리의 자동완성은 없어집니다. 정말 임시방편입니다.
2) 제대로 타입 적기 — 부분 선언 파일 #
라이브러리에서 자주 쓰는 함수만 골라서 타입을 직접 적는 게 다음 단계입니다.
declare module 'some-old-lib' {
export function compute(input: number): number;
export function format(value: string, options?: { lowercase?: boolean }): string;
}declare module '...' 블록 안에 import 했을 때 사용할 함수의 시그니처를 직접 적습니다. 모든 API를 적을 필요는 없고, 내가 쓰는 부분만 적으면 충분합니다. 시간이 지나며 늘려가면 됩니다.
이 패턴은 자주 쓰입니다. 실무에서 외부 라이브러리 타입이 부족할 때, 자기 프로젝트 안에 부분 선언 파일을 두고 통제하는 게 흔한 방식입니다.
Module augmentation — 기존 모듈에 추가하기 #
기존 모듈에 타입을 더하는 것도 가능합니다. 예를 들어 next-auth의 세션 타입에 우리 앱의 추가 필드를 붙이고 싶을 때 — 패키지를 fork 하지 않고 우리 프로젝트에서 확장합니다.
import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'admin' | 'user';
name?: string | null;
email?: string | null;
};
}
}import 'next-auth'부터 적는 게 핵심입니다. 모듈 import가 있는 파일이어야 declare module이 augmentation으로 동작합니다. import가 없으면 새 모듈을 선언하는 것으로 해석됩니다.
interface Session은 **선언 병합(declaration merging)**으로 원본의 Session 인터페이스에 우리가 적은 필드들이 합쳐집니다. 이 방식이 동작하는 이유가 기초 강좌 #3에서 다뤘던 interface의 확장 가능성입니다. type alias는 선언 병합이 안 되니까, augmentation 용도에는 interface가 거의 항상 정답입니다.
import.meta.env 확장 — Vite 자주 만나는 패턴
#
Vite의 환경 변수 타입을 늘릴 때도 augmentation을 씁니다.
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_FEATURE_FLAGS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}/// <reference types="vite/client" />는 Triple-slash directive 라고 불리는 특수 주석. Vite가 제공하는 기본 타입을 끌어옵니다. 그 위에 우리 프로젝트의 환경 변수 키들을 인터페이스로 더합니다. 이러면 코드에서 import.meta.env.VITE_API_URL 자동완성과 타입 추론이 정확히 됩니다.
Next.js도 같은 방식 #
Next.js의 process.env 자동완성을 켜고 싶다면:
declare namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string;
readonly NEXT_PUBLIC_API_URL: string;
}
}namespace NodeJS는 Node 타입 정의에 들어 있는 글로벌 namespace입니다. 거기에 ProcessEnv 인터페이스를 augment 하면 process.env의 키들이 자동완성됩니다.
글로벌 타입 추가 — declare global
#
모듈 안에서 글로벌(window 같은 객체)에 타입을 추가하려면 declare global 블록을 씁니다.
export {}; // 이 파일을 모듈로 만들기 위해 필요
declare global {
interface Window {
gtag: (...args: unknown[]) => void;
myAppVersion: string;
}
}두 가지 핵심 포인트.
export {}를 위에 두면이.d.ts가 모듈 파일이 됩니다. 그래야declare global이 의미를 가집니다. 그 한 줄이 빠지면 파일 전체가 글로벌 스크립트로 해석돼서declare global자체가 불필요한 곳이 됩니다.Window는 lib.dom.d.ts의 인터페이스 — interface라서 augmentation가능.window.gtag(...)같은 호출이 타입스크립트에서 자동완성됩니다.
globalThis, process.env, 또는 직접 만든 글로벌 객체에 타입을 더할 때 모두 같은 패턴입니다.
Triple-slash directive — 옛날 방식의 흔적 #
/// <reference path="..." />, /// <reference types="..." />, /// <reference lib="..." /> 같은 특수 주석을 옛날 코드에서 종종 봅니다. ESM 표준이 정착한 요즘은 거의 안 쓰지만, 한 곳에서는 아직 흔합니다 — 선언 파일이 다른 선언 파일을 끌어올 때.
위에서 본 vite-env.d.ts의 /// <reference types="vite/client" />가 그 예입니다. Vite의 기본 환경 타입을 import 없이 끌어와 augment 하는 부분입니다.
직접 짤 일은 거의 없고, 이런 줄을 만나면 “선언 파일이 다른 선언 파일을 의존하고 있구나” 정도로만 읽으면 충분합니다.
tsconfig.json의 typeRoots / types #
내 프로젝트가 어떤 선언 파일을 자동으로 들여올지 결정하는 옵션이 두 개 있습니다.
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node", "vite/client"]
}
}typeRoots— 자동으로 타입 정의를 찾는 폴더. 기본값은./node_modules/@types. 우리 프로젝트의 추가 폴더(src/types)를 더하고 싶을 때 명시.types—typeRoots안의 패키지 중 이 목록만 자동 포함. 명시하지 않으면 typeRoots 안의 모든 패키지를 자동으로 들여옵니다.
types를 굳이 채우지 마세요. 비워 두면 필요한 게 알아서 들어옵니다. 명시할 때는 보통 “이 프로젝트에서는 React DOM 타입을 일부러 빼고 싶다” 같은 특수 상황입니다.
자주 만드는 실수 — types에 한 줄 적는 순간 다 막힘
#
{
"compilerOptions": {
"types": ["node"] // ⚠️ React, vite/client 등이 모두 빠짐
}
}types가 빈 배열로라도 정의되는 순간 그 목록만 자동 포함됩니다. “node만 추가하고 나머지는 그대로” 가 의도였다면, 사실은 types 자체를 안 적는 게 맞습니다.
현실적으로 자주 만나는 경우 #
이 글에서 다룬 도구들 중 보통 일하면서 만나는 경우는 한정적입니다.
- 외부 라이브러리에 타입이 없을 때 →
declare module 'pkg' { ... }로 부분 선언 - next-auth/Vite/Next.js 환경 타입 확장 → augmentation 패턴
- window/global에 속성 추가 →
declare global { interface Window { ... } } - 자체 라이브러리 만들 때 →
package.json의types필드 +.d.ts빌드 출력
라이브러리를 직접 만들지 않는 일반 앱 개발자라면, 위 네 가지 경우에서 패턴을 알아보는 정도가 실용적입니다.
마무리 #
이번 글에서 정리한 내용:
- 모듈 타입의 출처 — 직접 작성 / 패키지 동봉 /
@types/... .d.ts— 구현 없이 시그니처만.declare키워드- 타입 없는 라이브러리 빠르게 막기 —
declare module 'pkg'; - 부분 선언 — 쓰는 함수만 시그니처 작성
- module augmentation —
import 'pkg'; declare module 'pkg' { ... } - 글로벌 augmentation —
export {}; declare global { ... } tsconfig.json의typeRoots/types의미와 함정
다음 글(#7 실전 패턴과 안티패턴)에서는 시리즈 마지막으로, 좋은 타입과 과한 타입을 가르는 기준 — any vs unknown vs never, as const와 satisfies, 그리고 자주 빠지는 안티패턴들을 정리합니다.