모듈 `.d.ts` 작성법

모듈 .d.ts 작성법 (Modules .d.ts)

모듈 하나를 위한 타입 정의를 처음 쓴다면, 어디서부터 시작해야 할지가 늘 첫 고민이에요. 이 문서는 실제 자바스크립트 코드를 .d.ts로 옮길 때 자주 마주치는 CommonJS 패턴들을 하나씩 짚어주는 가이드입니다. 코드는 그대로 보존하되, "이게 왜 이렇게 선언되는지"를 옆에서 설명해 드릴게요.

출처: TypeScript 핸드북

자바스크립트와 .d.ts 예시 비교

흔한 CommonJS 패턴들

CommonJS 패턴을 쓰는 모듈은 module.exports로 내보내는 값을 표현해요. 예를 들어, 함수 하나와 숫자 상수 하나를 내보내는 모듈을 볼게요.

const maxInterval = 12;

function getArrayLength(arr) {
  return arr.length;
}

module.exports = {
  getArrayLength,
  maxInterval,
};

이 모듈은 다음과 같은 .d.ts로 표현할 수 있어요.

export function getArrayLength(arr: any[]): number;
export const maxInterval: 12;

TypeScript 플레이그라운드는 자바스크립트 코드에 해당하는 .d.ts를 직접 보여줍니다. 이 링크에서 직접 확인해 보세요.

.d.ts 문법은 의도적으로 ES Modules 문법처럼 보이게 만들어졌어요. ES Modules는 TC39에서 2015년 ES2015(ES6)의 일부로 비준됐고, 그 전부터 트랜스파일러를 통해 오랫동안 사용돼 왔죠. 자바스크립트 코드베이스가 ES Modules를 쓴다면, 이런 함수는:

export function getArrayLength(arr) {
  return arr.length;
}

다음과 같은 .d.ts가 됩니다.

export function getArrayLength(arr: any[]): number;

기본 내보내기 (Default Exports)

CommonJS에서는 어떤 값이든 기본 내보내기로 내보낼 수 있어요. 예를 들어 정규 표현식을 내보내는 모듈을 볼게요.

module.exports = /hello( world)?/;

이는 다음과 같은 .d.ts로 표현할 수 있어요.

declare const helloWorld: RegExp;
export = helloWorld;

숫자를 내보낸다면:

module.exports = 3.142;
declare const pi: number;
export = pi;

CommonJS에서 자주 쓰는 내보내기 방식 중 하나가 함수를 내보내는 거예요. 함수는 객체이기도 하기 때문에, 추가 필드를 붙일 수 있고 그 필드도 내보내기에 포함됩니다.

function getArrayLength(arr) {
  return arr.length;
}
getArrayLength.maxInterval = 12;

module.exports = getArrayLength;

이건 이렇게 표현할 수 있어요.

declare function getArrayLength(arr: any[]): number;
declare namespace getArrayLength {
  declare const maxInterval: 12;
}

export = getArrayLength;

이게 어떻게 동작하는지는 모듈: 함수 (Module: Functions)Modules 참고 문서를 참고하세요.

다양한 소비자 import 처리하기

요즘 소비 코드에서는 모듈을 import 하는 방식이 정말 다양합니다.

const fastify = require("fastify");
const { fastify } = require("fastify");
import fastify = require("fastify");
import * as Fastify from "fastify";
import { fastify, FastifyInstance } from "fastify";
import fastify from "fastify";
import fastify, { FastifyInstance } from "fastify";

이 모든 경우를 다 커버하려면 자바스크립트 코드가 실제로 그 패턴들을 전부 지원해야 해요. 이런 패턴들을 많이 지원하려면 CommonJS 모듈은 대략 이렇게 생겨야 합니다.

class FastifyInstance {}

function fastify() {
  return new FastifyInstance();
}

fastify.FastifyInstance = FastifyInstance;

// Allows for { fastify }
fastify.fastify = fastify;
// Allows for strict ES Module support
fastify.default = fastify;
// Sets the default export
module.exports = fastify;

모듈 안의 타입

자바스크립트 코드에는 존재하지 않는 타입을 제공하고 싶을 때가 있어요. 다음 모듈을 볼게요.

function getArrayMetadata(arr) {
  return {
    length: getArrayLength(arr),
    firstObject: arr[0],
  };
}

module.exports = {
  getArrayMetadata,
};

이건 이렇게 표현할 수 있어요.

export type ArrayMetadata = {
  length: number;
  firstObject: any | undefined;
};
export function getArrayMetadata(arr: any[]): ArrayMetadata;

이 예시는 제네릭 사용이 더 풍부한 타입 정보를 제공하기 좋은 사례예요.

export type ArrayMetadata<ArrType> = {
  length: number;
  firstObject: ArrType | undefined;
};

export function getArrayMetadata<ArrType>(
  arr: ArrType[]
): ArrayMetadata<ArrType>;

이제 배열의 타입이 ArrayMetadata 타입 안으로 그대로 전파됩니다.

내보낸 타입은 모듈의 소비자가 TypeScript 코드에서 importimport type으로 재사용하거나, JSDoc imports로 가져다 쓸 수 있어요.

모듈 코드 안의 Namespace

자바스크립트 코드의 런타임 관계를 타입으로 표현하는 일은 꽤 까다롭습니다. ES Module 같은 문법으로 내보내기를 설명하기에 부족할 때 namespaces를 쓰면 돼요.

예를 들어, 타입이 복잡해서 .d.ts 안에서 namespace로 묶어두고 싶은 경우입니다.

// This represents the JavaScript class which would be available at runtime
export class API {
  constructor(baseURL: string);
  getInfo(opts: API.InfoRequest): API.InfoResponse;
}

// This namespace is merged with the API class and allows for consumers, and this file
// to have types which are nested away in their own sections.
declare namespace API {
  export interface InfoRequest {
    id: string;
  }

  export interface InfoResponse {
    width: number;
    height: number;
  }
}

.d.ts 파일에서 namespace가 어떻게 동작하는지 이해하려면 .d.ts 심층 분석 문서를 읽어보세요.

선택적인 전역 사용 (Optional Global Usage)

export as namespace를 쓰면 UMD 환경에서 모듈이 전역 스코프에서도 사용 가능하다고 선언할 수 있어요.

export as namespace moduleName;

참고 예시 (Reference Example)

이 모든 조각이 어떻게 하나로 모이는지 감을 잡기 위해, 새 모듈을 만들 때 출발점으로 삼을 만한 참고 .d.ts를 보여드릴게요.

// Type definitions for [~THE LIBRARY NAME~] [~OPTIONAL VERSION NUMBER~]
// Project: [~THE PROJECT NAME~]
// Definitions by: [~YOUR NAME~] <[~A URL FOR YOU~]>

/*~ This is the module template file. You should rename it to index.d.ts
 *~ and place it in a folder with the same name as the module.
 *~ For example, if you were writing a file for "super-greeter", this
 *~ file should be 'super-greeter/index.d.ts'
 */

/*~ If this module is a UMD module that exposes a global variable 'myLib' when
 *~ loaded outside a module loader environment, declare that global here.
 *~ Otherwise, delete this declaration.
 */
export as namespace myLib;

/*~ If this module exports functions, declare them like so.
 */
export function myFunction(a: string): string;
export function myOtherFunction(a: number): number;

/*~ You can declare types that are available via importing the module */
export interface SomeType {
  name: string;
  length: number;
  extras?: string[];
}

/*~ You can declare properties of the module using const, let, or var */
export const myField: number;

라이브러리 파일 배치

선언 파일의 배치(layout)는 라이브러리의 배치를 그대로 따라야 합니다. 라이브러리는 여러 모듈로 구성될 수 있어요.

myLib
  +---- index.js
  +---- foo.js
  +---- bar
         +---- index.js
         +---- baz.js

이들은 이렇게 import 될 수 있어요.

var a = require("myLib");
var b = require("myLib/foo");
var c = require("myLib/bar");
var d = require("myLib/bar/baz");

그렇다면 선언 파일도 이렇게 되어야 합니다.

@types/myLib
  +---- index.d.ts
  +---- foo.d.ts
  +---- bar
         +---- index.d.ts
         +---- baz.d.ts

타입 테스트하기 (Testing your types)

이 변경을 DefinitelyTyped에 제출해서 모두가 쓰게 하려면, 다음을 권장합니다.

  1. node_modules/@types/[libname] 안에 새 폴더를 만든다
  2. 그 폴더에 index.d.ts를 만들고 예시를 복사한다
  3. 모듈을 쓰는 코드가 어디서 깨지는지 확인하며 index.d.ts를 채워나간다
  4. 만족스러우면 DefinitelyTyped/DefinitelyTyped를 클론하고 README의 지침을 따른다

그게 아니라면:

  1. 소스 트리 루트에 새 파일을 만든다: [libname].d.ts
  2. declare module "[libname]" { }를 추가한다
  3. declare module의 중괄호 안에 템플릿을 넣고, 사용하는 코드가 어디서 깨지는지 확인한다

더 알아보기 (Learn more)