지금까지 다섯 편은 모두 내가 짠 타입을 어떻게 가공하는가 였습니다. 이번 글은 그 너머 — 외부에서 들어오는 타입을 어떻게 받아들이고 확장하는가 가 주제입니다.
같은 코드인데도 어떤 모듈은 자동으로 타입이 잡히고, 어떤 라이브러리는 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, 그리고 자주 빠지는 안티패턴들을 정리합니다.