본문 바로가기

앰비언트 모듈 선언

파일 하나에 묶이지 않고 이름으로 모듈의 모양을 알리는 declare module 선언을 script 문맥과 module 문맥으로 나누어 확인하고, 이미지와 CSS 같은 자산을 위한 와일드카드 선언이 로더의 실제 출력과 맞아야 하는 이유, 모듈 파일에서 전역을 넓히는 declare global을 다룹니다.

앞의 두 글에서 선언 파일은 대개 설명할 자바스크립트 파일과 짝을 이루었습니다. 그런데 설치했지만 타입이 없는 패키지나, 번들러가 처리하는 이미지와 스타일시트처럼 짝지을 .ts 파일이 없는 대상도 있습니다. 이럴 때는 파일의 위치가 아니라 모듈의 이름으로 그 모양을 알리는 앰비언트 모듈 선언(ambient module declaration)을 씁니다.

이 글은 declare module "이름"이 선언 파일의 문맥에 따라 뜻이 달라진다는 점을 먼저 확인하고, 자산을 위한 와일드카드 선언과 모듈 파일에서 전역을 넓히는 declare global을 차례로 다룹니다.

실제 bare 모듈·와일드카드 자산·전역 스크립트와 module 파일의 확장을 구분합니다.

TYPE / RUNTIME / CONTRACT

외부 모듈·자산·전역의 선언 문맥을 구분한다

선언할 대상과 파일의 script/module 문맥을 먼저 확인합니다. 같은 declare 문법이라도 새 외부 모듈 설명과 기존 모듈 확장, 전역 설명은 역할이 다릅니다.

외부 모듈·자산·전역의 선언 문맥을 구분한다이 절의 d.ts 예제에서 최상위 import/export가 없는 script와 있는 module 문맥을 구분합니다. script 아래의 모듈·자산 설명과 전역 설명은 대상에 따른 선택이며 실행 순서가 아닙니다. 확장은 기존 모듈을 전제로 합니다.없음있음모듈·자산전역최상위import/export?script 문맥새 선언 설명모듈·자산 설명declare module전역 값 설명namespace · varmodule 문맥기존 선언 확장모듈 확장augmentation
외부 모듈·자산·전역의 선언 문맥을 구분한다이 절의 d.ts 예제에서 최상위 import/export가 없는 script와 있는 module 문맥을 구분합니다. script 아래의 모듈·자산 설명과 전역 설명은 대상에 따른 선택이며 실행 순서가 아닙니다. 확장은 기존 모듈을 전제로 합니다.없음있음모듈·자산전역최상위import/export?script 문맥새 선언 설명모듈·자산 설명declare module전역 값 설명namespace · varmodule 문맥기존 선언 확장모듈 확장augmentation

이 절의 d.ts 예제에서 최상위 import/export가 없는 script와 있는 module 문맥을 구분합니다. script 아래의 모듈·자산 설명과 전역 설명은 대상에 따른 선택이며 실행 순서가 아닙니다. 확장은 기존 모듈을 전제로 합니다.

BARE MODULE

some-legacy-library의 API를 선언한다

최상위 import/export가 없는 선언 파일에서 Options, initialize, fetchData<T>, version, default SomeClient를 설명합니다. initialize는 debugMode와 logLevel을 요구합니다.

FILE CONTEXT

모듈 파일이면 기존 모듈 확장이다

최상위 import/export가 있으면 declare module은 기존 모듈 augmentation이 됩니다. 새 ambient 모듈 선언을 모듈 파일로 바꾸면 의미가 달라집니다.

WILDCARD

로더가 제공하는 export를 설명한다

*.png·*.svg·*.module.css 패턴은 자산 import의 타입입니다. SVG 선언 블록 내부의 React import는 바깥 선언 파일을 모듈로 바꾸지 않습니다.

GLOBAL SCRIPT

기존 전역 이름과 객체를 설명한다

MY_APP, initializeApp, Window._paq, MyGlobalUtils는 script 문맥의 전역 선언입니다. 모듈 파일에서 전역을 확장하려면 declare global을 사용합니다.

PROGRAM

선언 파일이 프로그램에 포함돼야 한다

include/files 등으로 선언을 타입 검사에 포함합니다. fetchData<{name:string;value:number}>는 응답에 타입을 부여하지만 실제 응답 구조를 검증하지 않습니다.

@TYPES

배포 경로와 선언 문법을 구분한다

@types 패키지는 모듈 선언·전역 선언·확장 등 여러 형태의 타입을 배포합니다. @types를 declare module 한 문법과 동일한 개념으로 읽지 않습니다.

어느 선언 문맥도 라이브러리·로더·전역 값의 실제 구현을 생성하지 않습니다.

위 그림처럼 먼저 선언 파일의 최상위에 import나 export가 있는지를 봅니다. 없으면 script 문맥이어서 그 안의 declare module은 새 모듈을 설명하고 다른 선언은 전역에 들어가며, 있으면 module 문맥이어서 declare module은 이미 있는 모듈을 넓히는 확장이 됩니다.

이름으로 모듈 선언하기

declare module "패키지 이름" { ... } 블록 안에는 그 모듈이 내보내는 것을 export로 적습니다. 선언 파일이 script 문맥이면, 이 블록은 그 이름의 모듈이 이런 모양이라고 새로 알리는 선언입니다.

types/some-legacy-library.d.ts
declare module "some-legacy-library" {
  export interface Options {
    debugMode: boolean;
    logLevel: "info" | "warn" | "error";
  }

  export function initialize(options: Options): void;
  export function fetchData<T>(url: string): Promise<T>;
  export const version: string;

  export default class SomeClient {
    constructor(apiKey: string);
    sendRequest(data: unknown): Promise<unknown>;
  }
}

이 선언이 프로젝트에 있으면 import SomeClient, { initialize, version } from "some-legacy-library";가 검사를 통과하고, initialize({})는 debugMode와 logLevel이 빠졌다는 진단을 받습니다. 하지만 실행하려면 이 이름의 패키지가 실제로 설치되어 같은 내보내기를 제공해야 합니다. 또 fetchData<T>의 T는 호출한 쪽이 정하는 타입일 뿐이어서, 서버가 정말 그 모양을 돌려주는지는 응답을 받은 곳에서 따로 검증해야 합니다.

외부 패키지 없이 선언의 문맥을 확인해 봅니다. 새 폴더 ts-ambient에 package.json을 { "type": "commonjs" }로 두고 다음 파일을 만듭니다. 이 실습은 noEmit으로 타입 검사만 하므로 아무것도 실행하지 않습니다.

types/lesson-source.d.ts
declare module "lesson-source" {
  export function readCount(): number;
}
app.ts
import { readCount } from "lesson-source";

const count: number = readCount();
// const label: string = readCount();
tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "strict": true,
    "noEmit": true
  },
  "files": ["types/lesson-source.d.ts", "app.ts"]
}

tsc -p .을 실행하면 진단 없이 끝나고, 주석을 풀면 숫자를 문자열 변수에 넣으므로 Type 'number' is not assignable to type 'string'.이 나옵니다. 검사가 통과했다는 것은 lesson-source의 모양이 알려졌다는 뜻일 뿐, 그 모듈이 존재하거나 readCount가 실행되었다는 뜻이 아닙니다.

이제 lesson-source.d.ts의 맨 위에 export {};를 더하면 결과가 달라집니다. 파일이 module 문맥이 되어 declare module "lesson-source"가 이미 있는 모듈을 넓히는 확장으로 해석되는데, 넓힐 원래 모듈이 없으므로 Invalid module name in augmentation, module 'lesson-source' cannot be found.라는 진단이 나옵니다. 같은 문법이라도 파일의 문맥에 따라 새 선언과 확장으로 뜻이 갈린다는 것을 보여 줍니다.

더 깊이: 모듈 확장으로 할 수 있는 것과 없는 것

module 문맥의 declare module, 곧 모듈 확장(module augmentation)은 주로 이미 있는 모듈에 실행 중에 무언가를 덧붙이는 코드를 설명할 때 씁니다. 공식 Handbook의 선언 병합 글은 Observable 클래스의 prototype에 map 메서드를 붙이는 파일을 예로 듭니다. 그 파일 안에서 declare module "./observable"로 interface Observable<T>에 map을 더하면, 확장의 선언은 원래 모듈과 같은 파일에 적은 것처럼 합쳐집니다. 이때 모듈 이름은 import의 경로와 같은 방식으로 해석됩니다. 다만 확장은 타입만 알릴 뿐이어서, 실제로 메서드를 붙이는 그 파일을 어딘가에서 import "./map"처럼 불러와야 실행 중에도 map이 생깁니다.

Handbook은 확장의 제한도 두 가지 적어 둡니다. 첫째, 확장 안에서 새 최상위 선언을 만들 수는 없고 이미 있는 선언을 고치는 것만 할 수 있습니다. 둘째, 확장은 내보낸 이름으로 대상을 찾으므로 이름 있는 내보내기만 넓힐 수 있고 기본 내보내기는 넓힐 수 없습니다. 이 글의 뒤에서 볼 declare global도 같은 동작과 제한을 따른다고 Handbook은 설명합니다.

자산을 위한 와일드카드 선언

번들러를 쓰는 프로젝트에서는 이미지와 스타일시트도 import합니다. 이런 파일은 자바스크립트가 아니므로 컴파일러가 모양을 알 수 없고, 그래서 *로 이름의 패턴을 적은 와일드카드 모듈 선언을 둡니다.

types/assets.d.ts
declare module "*.png" {
  const src: string;
  export default src;
}

declare module "*.svg" {
  import type { FunctionComponent, SVGProps } from "react";
  export const ReactComponent: FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>;
  const src: string;
  export default src;
}

declare module "*.module.css" {
  const classes: { readonly [className: string]: string };
  export default classes;
}

declare module "*.json" {
  const value: any;
  export default value;
}

이 선언이 있으면 import logo from "./logo.png";의 logo는 문자열로, import styles from "./main.module.css";의 styles는 클래스 이름을 키로 가진 객체로 검사됩니다. SVG 선언은 React 프로젝트에서 SVG를 URL과 컴포넌트 두 가지로 가져오는 구성을 위한 것이어서, React의 타입을 가져옵니다.

PNG URL, SVG의 default URL 및 named 컴포넌트, CSS Modules 맵과 JSON any의 서로 다른 계약을 설명합니다.

TYPE / RUNTIME / CONTRACT

자산 선언의 export 형식을 실제 로더와 맞춘다

본문은 PNG URL, SVG URL과 named ReactComponent, CSS Modules 클래스 맵을 선언합니다. 같은 import가 실행에서도 그 형식을 제공하도록 로더 설정을 맞춰야 합니다.

자산 선언의 export 형식을 실제 로더와 맞춘다하나의 import에는 정적 선언 해석과 실제 로더 처리가 각각 있습니다. 양쪽의 export 이름과 값 형식을 대조해야 합니다. 타입 경로를 통과했다는 사실만으로 로더가 named 컴포넌트를 만든다고 판단하지 않습니다.같은 자산 importPNG · SVG · CSS타입 해석선언 파일 포함선언한 exportURL · 컴포넌트·맵실제 로더파일 처리제공한 exportURL · 컴포넌트·맵두 계약이 일치?실제 사용으로 확인타입 경로실행 경로
자산 선언의 export 형식을 실제 로더와 맞춘다하나의 import에는 정적 선언 해석과 실제 로더 처리가 각각 있습니다. 양쪽의 export 이름과 값 형식을 대조해야 합니다. 타입 경로를 통과했다는 사실만으로 로더가 named 컴포넌트를 만든다고 판단하지 않습니다.같은 자산 importPNG · SVG · CSS타입 해석선언 파일 포함선언한 exportURL · 컴포넌트·맵실제 로더파일 처리제공한 exportURL · 컴포넌트·맵두 계약이 일치?실제 사용으로 확인타입 경로실행 경로대조

하나의 import에는 정적 선언 해석과 실제 로더 처리가 각각 있습니다. 양쪽의 export 이름과 값 형식을 대조해야 합니다. 타입 경로를 통과했다는 사실만으로 로더가 named 컴포넌트를 만든다고 판단하지 않습니다.

PNG

default URL 문자열

logo.png의 default import logo는 string입니다. 실제 빌드가 해당 URL을 제공해야 하며 선언만으로 이미지 처리나 파일 출력이 수행되지는 않습니다.

SVG

URL과 컴포넌트의 이름을 구분한다

본문 *.svg는 default src:string과 named ReactComponent를 함께 선언합니다. Icon은 named 컴포넌트 별칭입니다. 이런 두 export를 실제로 제공하는 설정이 필요하며 SVGR의 기본 단독 설정과 같다고 가정하지 않습니다.

CSS MODULES

클래스 이름을 문자열로 매핑한다

*.module.css의 default styles는 className→string 맵입니다. styles.container는 변환된 클래스 이름을 가리킵니다. 일반 CSS의 side-effect import와 이 맵 계약은 다릅니다.

JSON

any 선언은 구조 검사를 포기한다

본문 *.json의 any는 어떤 사용도 허용할 수 있습니다. 실제 JSON import에는 resolveJsonModule 등 구체적인 해석을 검토하고 실행 환경의 JSON 로드 방식도 맞춥니다.

타입 오류가 없어도 로더의 실제 export가 다르면 사용이 실패할 수 있습니다. 선택한 로더로 빌드하고 URL·컴포넌트·클래스 맵을 실제로 사용하는 지점을 확인합니다.

위 그림처럼 같은 import 하나를 컴파일러는 선언으로, 번들러는 로더(loader)의 설정으로 각각 처리합니다. 그래서 선언이 약속한 내보내기의 이름과 값의 모양을 로더가 실제로 만들어 주는지 대조해야 합니다. 예를 들어 선언에 ReactComponent를 적었더라도, 로더가 SVG를 URL 문자열로만 바꾸어 준다면 검사는 통과하지만 실행 중에 ReactComponent는 undefined입니다.

JSON에 any를 주는 마지막 선언은 어떤 JSON이든 받아 주는 대신 모든 구조 검사를 포기합니다. 컴파일러에는 JSON 파일을 직접 읽어 정확한 타입을 만들어 주는 resolveJsonModule 옵션이 있으며, 실제 파일을 찾을 수 있으면 와일드카드 선언보다 그 파일의 타입이 먼저 쓰입니다. 그러니 JSON은 이 옵션을 먼저 검토하고, 와일드카드 선언은 실제 파일을 해석할 수 없는 경우를 위해 남겨 둡니다.

모듈 파일에서 전역 넓히기

<script> 태그로 불러오는 분석 도구처럼 전역에 값을 만드는 코드에는, 8장 2편에서 본 것처럼 script 문맥의 선언 파일에 declare var나 interface Window를 적습니다. 그런데 이미 import가 있는 모듈 파일에서 전역을 넓히고 싶다면, 그 선언을 declare global 블록 안에 넣어야 합니다.

types/analytics.d.ts
import type { AnalyticsEvent } from "./events";

declare global {
  interface Window {
    analyticsQueue?: AnalyticsEvent[];
  }
  function trackEvent(event: AnalyticsEvent): void;
}

export {};

이 파일은 import가 있는 module 문맥이므로, declare global이 없으면 그 안의 interface Window는 이 파일 안에서만 보이는 새 인터페이스가 됩니다. declare global 블록 안에 적은 선언만 전역의 Window와 합쳐지고 전역 함수 trackEvent도 생기며, 마지막의 export {};는 다른 가져오기를 지우더라도 이 파일을 모듈로 유지합니다. 앞의 선언들과 마찬가지로 declare global도 값을 만들지 않으므로, 실제 trackEvent 함수는 분석 도구의 스크립트가 먼저 만들어 두어야 합니다.

정리

  • 앰비언트 모듈 선언 declare module "이름"은 파일의 위치가 아니라 모듈의 이름으로 그 모양을 알립니다.
  • 최상위에 import나 export가 없는 script 문맥에서는 새 모듈을 선언하고, 있는 module 문맥에서는 이미 있는 모듈을 넓히는 확장이 됩니다.
  • 와일드카드 선언 declare module "*.png"는 자산의 import를 검사하게 하지만, 내보내기의 이름과 값은 로더가 실제로 만드는 것과 맞아야 합니다.
  • JSON은 any를 주는 와일드카드보다 resolveJsonModule로 실제 파일의 타입을 쓰는 편이 정확합니다.
  • 모듈 파일에서 전역을 넓히려면 선언을 declare global 블록 안에 넣으며, 어떤 앰비언트 선언도 실제 값을 만들지 않습니다.

연습 문제

1. 로더와 맞지 않는 SVG 선언

어떤 프로젝트의 번들러는 SVG를 URL 문자열로만 바꾸어 기본 내보내기로 줍니다. 위의 *.svg 선언을 그대로 두고 import { ReactComponent as Icon } from "./icon.svg";를 쓰면 검사와 실행은 각각 어떻게 되나요?

해설 보기

선언에 ReactComponent가 있으므로 검사는 통과합니다. 하지만 로더는 기본 내보내기의 URL만 만들고 ReactComponent라는 내보내기는 만들지 않으므로, 실행하면 Icon이 undefined이고 그것을 컴포넌트로 그리려는 순간 오류가 납니다. URL만 쓸 것이라면 선언에서 ReactComponent를 빼서 실제와 맞추고, 컴포넌트가 필요하다면 그것을 만들어 주는 로더 설정을 더합니다.

2. 문맥을 바꾼 선언

types/lesson-source.d.ts에 export {};를 더했을 때 나오는 진단을 없애면서 export {};도 남겨 두려면, declare module 블록을 어떻게 바꾸어야 하나요?

해설 보기

module 문맥에서 새 모듈을 선언할 수는 없으므로, 그 파일을 모듈 자신의 선언으로 바꾸는 방법이 있습니다. declare module 블록을 없애고 최상위에 export declare function readCount(): number;를 두면, 그 파일은 자기 경로의 모듈을 설명하게 됩니다. 그러면 app.ts도 "lesson-source"라는 이름이 아니라 그 파일의 상대 경로로 가져와야 하므로, 이름으로 가져오려면 export {};를 지우고 script 문맥으로 되돌리는 편이 간단합니다.

3. declare global을 빠뜨렸을 때

types/analytics.d.ts에서 declare global 블록을 지우고 interface Window와 function trackEvent를 최상위에 두면, 다른 파일의 window.analyticsQueue와 trackEvent(...)는 어떻게 되나요?

해설 보기

먼저 .d.ts 파일의 최상위 함수 선언에는 declare나 export가 있어야 하므로, declare 없이 옮긴 function trackEvent는 그 줄에서 Top-level declarations in .d.ts files must start with either a 'declare' or 'export' modifier.(TS1046)를 받습니다. declare function trackEvent로 적더라도 파일에 import와 export가 있으므로, 그 선언들은 이 모듈 안에서만 보이는 새 인터페이스와 함수가 됩니다. 그래서 다른 파일에서 window.analyticsQueue는 Window에 그런 속성이 없다는 진단을, trackEvent(...)는 Cannot find name 'trackEvent'.를 받습니다. 모듈 파일에서 전역에 무언가를 더하려면 반드시 declare global 안에 적어야 합니다.

다음 글 DefinitelyTyped와 @types에서는 직접 선언을 쓰기 전에 먼저 확인할, 공동체가 관리하는 @types 패키지와 그것을 고르고 설정하는 방법을 다룹니다.