모듈 - 레퍼런스
모듈 - 레퍼런스 (Modules - Reference)
모듈을 다루는 문법과 컴파일러 옵션을 참조할 수 있게 정리한 레퍼런스 문서예요. 모듈 문법, module 옵션 값별 동작, moduleResolution 옵션의 각 모드와 특징을 차례로 다룹니다. 코드 형태는 실제 방출 결과를 그대로 보여주므로, 설정을 바꿀 때 어떤 출력이 나올지 확인하는 용도로 쓰면 좋아요.
출처: TypeScript 핸드북
모듈 문법
TypeScript 컴파일러는 TypeScript 및 JavaScript 파일에서 표준 ECMAScript 모듈 문법을 인식하고, JavaScript 파일에서 다양한 CommonJS 문법 형태를 인식합니다. TypeScript 파일이나 JSDoc 주석에서 쓸 수 있는 TypeScript 고유의 문법 확장도 몇 가지 있어요.
TypeScript 고유 선언의 import/export
타입 별칭(type alias), 인터페이스, 열거형(enum), 네임스페이스는 표준 JavaScript 선언처럼 export 수정자로 모듈에서 내보낼 수 있어요:
// Standard JavaScript syntax...
export function f() {}
// ...extended to type declarations
export type SomeType = /* ... */;
export interface SomeInterface { /* ... */ }
또한 표준 JavaScript 선언에 대한 참조와 함께, 이름 붙은 export에서 이들을 참조할 수도 있어요:
export { f, SomeType, SomeInterface };
내보낸 타입(및 기타 TypeScript 고유 선언)은 표준 ECMAScript import로 가져올 수 있어요:
import { f, SomeType, SomeInterface } from "./module.js";
네임스페이스 import나 export를 쓰면, 내보낸 타입은 타입 위치에서 참조할 때 네임스페이스에서 사용할 수 있어요:
import * as mod from "./module.js";
mod.f();
mod.SomeType; // Property 'SomeType' does not exist on type 'typeof import("./module.js")'
let x: mod.SomeType; // Ok
타입 전용 import와 export
import·export를 JavaScript로 방출할 때, 기본적으로 TypeScript는 타입 위치에서만 쓰이는 import와 타입만 가리키는 export를 자동으로 제거(elide)해요(방출하지 않음). 타입 전용 import·export는 이 동작을 강제하고 제거를 명시적으로 만들기 위해 쓸 수 있어요. import type로 작성된 import 선언, export type { ... }로 작성된 export 선언, 그리고 type 키워드가 붙은 import·export 지정자는 모두 출력 JavaScript에서 제거된다는 것이 보장됩니다.
// @Filename: main.ts
import { f, type SomeInterface } from "./module.js";
import type { SomeType } from "./module.js";
class C implements SomeInterface {
constructor(p: SomeType) {
f();
}
}
export type { C };
// @Filename: main.js
import { f } from "./module.js";
class C {
constructor(p) {
f();
}
}
값도 import type으로 가져올 수 있지만, 출력 JavaScript에는 존재하지 않게 되므로 방출되지 않는 위치에서만 쓸 수 있어요:
import type { f } from "./module.js";
f(); // 'f' cannot be used as a value because it was imported using 'import type'
let otherFunction: typeof f = () => {}; // Ok
타입 전용 import 선언은 default import와 이름 붙은 바인딩을 동시에 선언할 수 없어요. type이 default import에 적용되는지 전체 import 선언에 적용되는지가 모호해 보이기 때문입니다. 대신 import 선언을 둘로 나누거나, default를 이름 붙은 바인딩으로 쓰면 돼요:
import type fs, { BigIntOptions } from "fs";
// ^^^^^^^^^^^^^^^^^^^^^
// Error: A type-only import can specify a default import or named bindings, but not both.
import type { default as fs, BigIntOptions } from "fs"; // Ok
import() 타입
TypeScript는 import 선언을 쓰지 않고 모듈의 타입을 참조하기 위해 JavaScript의 동적 import와 비슷한 타입 문법을 제공해요:
// Access an exported type:
type WriteFileOptions = import("fs").WriteFileOptions;
// Access the type of an exported value:
type WriteFileFunction = typeof import("fs").writeFile;
이것은 JavaScript 파일의 JSDoc 주석에서 특히 유용한데, 그 외에는 타입을 import할 방법이 없기 때문입니다:
/** @type {import("webpack").Configuration} */
module.exports = {
// ...
}
export =와 import = require()
CommonJS 모듈을 방출할 때, TypeScript 파일은 module.exports = ...와 const mod = require("...") JavaScript 문법의 직접적인 대응물을 쓸 수 있어요:
// @Filename: main.ts
import fs = require("fs");
export = fs.readFileSync("...");
// @Filename: main.js
"use strict";
const fs = require("fs");
module.exports = fs.readFileSync("...");
이 문법은 JavaScript 대응물보다 선호되어 왔는데, 변수 선언과 프로퍼티 할당은 TypeScript 타입을 참조할 수 없었지만 특수 TypeScript 문법은 그럴 수 있었기 때문입니다:
// @Filename: a.ts
interface Options { /* ... */ }
module.exports = Options; // Error: 'Options' only refers to a type, but is being used as a value here.
export = Options; // Ok
// @Filename: b.ts
const Options = require("./a");
const options: Options = { /* ... */ }; // Error: 'Options' refers to a value, but is being used as a type here.
// @Filename: c.ts
import Options = require("./a");
const options: Options = { /* ... */ }; // Ok
앰비언트 모듈 (Ambient modules)
TypeScript는 스크립트(비-모듈) 파일에서, 런타임에는 존재하지만 대응하는 파일이 없는 모듈을 선언하는 문법을 지원합니다. 이런 앰비언트 모듈 은 보통 Node.js의 "fs", "path" 같은 런타임 제공 모듈을 나타냅니다:
declare module "path" {
export function normalize(p: string): string;
export function join(...paths: any[]): string;
export var sep: string;
}
앰비언트 모듈이 TypeScript 프로그램에 로드되면, TypeScript는 다른 파일에서 선언된 모듈의 import를 인식해요:
// 👇 Ensure the ambient module is loaded -
// may be unnecessary if path.d.ts is included
// by the project tsconfig.json somehow.
/// <reference path="path.d.ts" />
import { normalize, join } from "path";
앰비언트 모듈 선언은 모듈 증강(module augmentation)과 동일한 문법을 쓰므로 헷갈리기 쉬워요. 이 모듈 선언 문법은 파일이 모듈일 때, 즉 최상위 import나 export 문이 있거나(--moduleDetection force 또는 auto의 영향을 받거나) 모듈 증강이 됩니다:
// Not an ambient module declaration anymore!
export {};
declare module "path" {
export function normalize(p: string): string;
export function join(...paths: any[]): string;
export var sep: string;
}
앰비언트 모듈은 모듈 선언 본문 안에서 import를 써서 다른 모듈을 참조할 수 있는데, 포함하는 파일을 모듈로 만들지 않으면서요(그렇게 하면 앰비언트 모듈 선언이 모듈 증강이 되어 버려요):
declare module "m" {
// Moving this outside "m" would totally change the meaning of the file!
import { SomeType } from "other";
export function f(): SomeType;
}
패턴 앰비언트 모듈은 이름에 * 와일드카드 문자 하나를 포함하는데, import 경로의 0개 이상의 문자와 매칭됩니다. 커스텀 로더가 제공하는 모듈을 선언하는 데 유용할 수 있어요:
declare module "*.html" {
const content: string;
export default content;
}
module 컴파일러 옵션
이 섹션은 각 module 컴파일러 옵션 값의 세부 사항을 다룹니다. 옵션이 무엇이고 전체 컴파일 과정에서 어떻게 맞물리는지에 대한 배경은 모듈 출력 형식 이론 섹션을 참고하세요. 간단히 말해 module 컴파일러 옵션은 역사적으로 방출된 JavaScript 파일의 출력 모듈 형식을 제어하는 데만 쓰였어요. 하지만 더 최근의 node16, node18, nodenext 값은 어떤 모듈 형식이 지원되는지, 각 파일의 모듈 형식을 어떻게 결정하는지, 서로 다른 모듈 형식이 어떻게 상호 운용되는지 등 Node.js 모듈 시스템의 광범위한 특성을 설명합니다.
node16, node18, node20, nodenext
Node.js는 CommonJS와 ECMAScript 모듈을 모두 지원하며, 각 파일이 어떤 형식일 수 있는지와 두 형식이 어떻게 상호 운용되도록 허용되는지에 대한 구체적인 규칙이 있어요. node16, node18, nodenext는 Node.js의 이중 형식 모듈 시스템의 전체 동작 범위를 설명하며, 각 파일을 CommonJS 또는 ESM 형식 중 하나로 방출합니다. 이는 다른 모든 module 옵션과 다르죠. 다른 옵션은 런타임에 구애받지 않고 모든 출력 파일을 단일 형식으로 강제해서, 출력이 자신의 런타임에 유효한지 확인하는 것을 사용자에게 맡깁니다.
흔한 오해는
node16—nodenext가 ES 모듈만 방출한다는 것입니다. 실제로 이 모드들은 ES 모듈을 지원 하는 Node.js 버전을 설명하는 것이지, ES 모듈을 사용 하는 프로젝트만을 위한 게 아니에요. 각 파일의 감지된 모듈 형식에 따라 ESM과 CommonJS 방출이 모두 지원됩니다. 이들은 Node.js의 이중 모듈 시스템의 복잡성을 반영하는 유일한module옵션이므로, Node.js v12 이상에서 실행하려는 모든 앱과 라이브러리의 유일한 올바른module옵션입니다. ES 모듈을 사용하든 아니든 말이죠.
고정 버전 node16와 node18 모드는 각각의 Node.js 버전에서 안정화된 모듈 시스템 동작을 나타내고, nodenext 모드는 Node.js의 최신 안정 버전에 따라 변합니다. 다음 표는 세 모드 간의 현재 차이를 요약합니다:
target |
moduleResolution |
import assertions | import attributes | JSON imports | require(esm) | |
|---|---|---|---|---|---|---|
| node16 | es2022 |
node16 |
❌ | ❌ | no restrictions | ❌ |
| node18 | es2022 |
node16 |
✅ | ✅ | needs type "json" |
❌ |
| nodenext | esnext |
nodenext |
❌ | ✅ | needs type "json" |
✅ |
모듈 형식 감지
.mts/.mjs/.d.mts파일은 항상 ES 모듈입니다..cts/.cjs/.d.cts파일은 항상 CommonJS 모듈입니다..ts/.tsx/.js/.jsx/.d.ts파일은 가장 가까운 조상 package.json 파일이"type": "module"을 포함하면 ES 모듈이고, 그렇지 않으면 CommonJS 모듈입니다.
입력 .ts/.tsx/.mts/.cts 파일의 감지된 모듈 형식이 방출된 JavaScript 파일의 모듈 형식을 결정합니다. 예를 들어 전부 .ts 파일로만 이루어진 프로젝트는 --module nodenext 아래에서 기본적으로 전부 CommonJS 모듈을 방출하며, 프로젝트 package.json에 "type": "module"을 추가하면 전부 ES 모듈을 방출하도록 만들 수 있어요.
상호 운용 규칙
- ES 모듈이 CommonJS 모듈을 참조할 때:
- CommonJS 모듈의
module.exports가 ES 모듈에 default import로 제공됩니다. - CommonJS 모듈
module.exports의 프로퍼티(default제외)는 ES 모듈에 이름 붙은 import로 제공될 수도, 아닐 수도 있어요. Node.js는 정적 분석을 통해 제공하려고 시도합니다. TypeScript는 선언 파일만으로는 그 정적 분석이 성공할지 알 수 없으므로 낙관적으로 성공한다고 가정해요. 이는 런타임에서 크래시가 날 수 있는 이름 붙은 import를 잡는 TypeScript의 능력을 제한합니다. 자세한 내용은 #54018을 참고하세요.
- CommonJS 모듈의
- CommonJS 모듈이 ES 모듈을 참조할 때:
node16와node18에서require는 ES 모듈을 참조할 수 없어요. TypeScript의 경우 이는 감지된 CommonJS 모듈 파일의import문을 포함하는데, 그import문이 방출된 JavaScript에서require호출로 변환되기 때문이에요.nodenext에서 Node.js v22.12.0 이상의 동작을 반영해require는 ES 모듈을 참조할 수 있어요. Node.js에서 ES 모듈이나 그 import 모듈 중 하나라도 최상위await를 쓰면 오류가 발생합니다. TypeScript는 이 경우를 감지하려 하지 않고 컴파일 타임 오류도 내지 않아요.require호출의 결과는 모듈의 Module Namespace Object, 즉 같은 모듈의await import()결과와 같습니다(아무것도await할 필요는 없지만요).- 동적
import()호출은 항상 ES 모듈을 import하는 데 쓸 수 있어요. 모듈의 Module Namespace Object의 Promise를 반환합니다(다른 ES 모듈에서import * as ns from "./module.js"로 얻는 것과 같아요).
방출 (Emit)
각 파일의 방출 형식은 각 파일의 감지된 모듈 형식으로 결정됩니다. ESM 방출은 --module esnext와 비슷하지만, --module esnext에서는 허용되지 않는 import x = require("...")에 대한 특수 변환이 있어요:
// @Filename: main.ts
import x = require("mod");
// @Filename: main.js
import { createRequire as _createRequire } from "module";
const __require = _createRequire(import.meta.url);
const x = __require("mod");
CommonJS 방출은 --module commonjs와 비슷하지만 동적 import() 호출은 변환되지 않아요. 여기 방출은 esModuleInterop이 켜진 상태로 보여줍니다:
// @Filename: main.ts
import fs from "fs"; // transformed
const dynamic = import("mod"); // not transformed
// @Filename: main.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs")); // transformed
const dynamic = import("mod"); // not transformed
암시되고 강제되는 옵션
--module nodenext는--moduleResolution nodenext를 암시하고 강제합니다.--module node18또는node16는--moduleResolution node16을 암시하고 강제합니다.--module nodenext는--target esnext를 암시합니다.--module node18또는node16은--target es2022를 암시합니다.--module nodenext또는node18또는node16은--esModuleInterop을 암시합니다.
요약
node16,node18,nodenext는 Node.js v12 이상에서 실행하려는 모든 앱과 라이브러리의 유일한 올바른module옵션입니다. ES 모듈을 쓰든 아니든요.node16,node18,nodenext는 각 파일의 감지된 모듈 형식에 따라 CommonJS 또는 ESM 형식으로 파일을 방출합니다.- ESM과 CJS 사이의 Node.js 상호 운용 규칙이 타입체크에 반영됩니다.
- ESM 방출은
import x = require("...")를createRequireimport로 구성된require호출로 변환합니다. - CommonJS 방출은 동적
import()호출을 변환하지 않으므로, CommonJS 모듈이 ES 모듈을 비동기적으로 import할 수 있어요.
preserve
--module preserve(TypeScript 5.4에서 추가)에서는 입력 파일에 쓰인 ECMAScript import·export가 출력에 보존되고, CommonJS식 import x = require("...")와 export = ... 문은 CommonJS require와 module.exports로 방출됩니다. 다시 말해, 각 개별 import·export 문의 형식이 보존되는 것이지, 전체 컴파일(또는 전체 파일)에 대해 단일 형식으로 강제되는 게 아니에요.
같은 파일에서 import와 require 호출을 섞을 필요는 드물지만, 이 module 모드는 대부분의 현대 번들러와 Bun 런타임의 능력을 가장 잘 반영해요.
번들러나 Bun에서
noEmit도 설정할 텐데, TypeScript의module방출을 왜 신경 써야 할까요? TypeScript의 타입체크와 모듈 해석 동작은 방출할 모듈 형식의 영향을 받기 때문이에요.module을 설정하면 번들러나 런타임이 import·export를 어떻게 처리할지에 대한 정보를 TypeScript에 주고, 이는 가져온 값에 보이는 타입이 런타임 또는 번들링 후 실제로 일어날 일을 정확히 반영하도록 보장합니다. 더 논의는--moduleResolution bundler를 참고하세요.
예시
// @Filename: main.ts
import x, { y, z } from "mod";
import mod = require("mod");
const dynamic = import("mod");
export const e1 = 0;
export default "default export";
// @Filename: main.js
import x, { y, z } from "mod";
const mod = require("mod");
const dynamic = import("mod");
export const e1 = 0;
export default "default export";
암시되고 강제되는 옵션
--module preserve는--moduleResolution bundler를 암시합니다.--module preserve는--esModuleInterop을 암시합니다.
--esModuleInterop옵션은--module preserve에서 그 타입체크 동작을 위해서만 기본적으로 켜집니다.--module preserve에서는 import가 require 호출로 변환되는 일이 없으므로,--esModuleInterop은 방출된 JavaScript에 영향을 주지 않아요.
es2015, es2020, es2022, esnext
요약
- 번들러, Bun, tsx에는
--moduleResolution bundler와 함께esnext를 쓰세요. - Node.js에는 쓰지 마세요. Node.js용 ES 모듈을 방출하려면 package.json에
"type": "module"을 넣고node16,node18,nodenext를 쓰세요. import mod = require("mod")는 비-선언 파일에서 허용되지 않습니다.es2020은import.meta프로퍼티 지원을 추가합니다.es2022는 최상위await지원을 추가합니다.esnext는 ECMAScript 모듈에 대한 Stage 3 제안 지원을 포함할 수 있는 이동 대상입니다.- 방출된 파일은 ES 모듈이지만, 의존성은 어떤 형식이든 될 수 있습니다.
예시
// @Filename: main.ts
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
// @Filename: main.js
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
commonjs
요약
- 아마 쓰면 안 됩니다. Node.js용 CommonJS 모듈을 방출하려면
node16,node18,nodenext를 쓰세요. - 방출된 파일은 CommonJS 모듈이지만, 의존성은 어떤 형식이든 될 수 있습니다.
- 동적
import()는require()호출의 Promise로 변환됩니다. esModuleInterop은 default import와 네임스페이스 import의 출력 코드에 영향을 줍니다.
예시
출력은
esModuleInterop: false로 보여줍니다.
// @Filename: main.ts
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
// @Filename: main.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.e1 = void 0;
const mod_1 = require("mod");
const mod = require("mod");
const dynamic = Promise.resolve().then(() => require("mod"));
console.log(mod_1.default, mod_1.y, mod_1.z, mod);
exports.e1 = 0;
exports.default = "default export";
// @Filename: main.ts
import mod = require("mod");
console.log(mod);
export = {
p1: true,
p2: false
};
// @Filename: main.js
"use strict";
const mod = require("mod");
console.log(mod);
module.exports = {
p1: true,
p2: false
};
system
요약
- SystemJS 모듈 로더와 함께 쓰도록 설계됐어요.
예시
// @Filename: main.ts
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
// @Filename: main.js
System.register(["mod"], function (exports_1, context_1) {
"use strict";
var mod_1, mod, dynamic, e1;
var __moduleName = context_1 && context_1.id;
return {
setters: [
function (mod_1_1) {
mod_1 = mod_1_1;
mod = mod_1_1;
}
],
execute: function () {
dynamic = context_1.import("mod");
console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);
exports_1("e1", e1 = 0);
exports_1("default", "default export");
}
};
});
amd
요약
- RequireJS 같은 AMD 로더용으로 설계됐어요.
- 아마 쓰면 안 됩니다. 대신 번들러를 쓰세요.
- 방출된 파일은 AMD 모듈이지만, 의존성은 어떤 형식이든 될 수 있습니다.
outFile을 지원합니다.
예시
// @Filename: main.ts
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
// @Filename: main.js
define(["require", "exports", "mod", "mod"], function (require, exports, mod_1, mod) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.e1 = void 0;
const dynamic = new Promise((resolve_1, reject_1) => { require(["mod"], resolve_1, reject_1); });
console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);
exports.e1 = 0;
exports.default = "default export";
});
umd
요약
- AMD 또는 CommonJS 로더용으로 설계됐어요.
- 대부분의 다른 UMD 래퍼처럼 전역 변수를 노출하지 않습니다.
- 아마 쓰면 안 됩니다. 대신 번들러를 쓰세요.
- 방출된 파일은 UMD 모듈이지만, 의존성은 어떤 형식이든 될 수 있습니다.
예시
// @Filename: main.ts
import x, { y, z } from "mod";
import * as mod from "mod";
const dynamic = import("mod");
console.log(x, y, z, mod, dynamic);
export const e1 = 0;
export default "default export";
// @Filename: main.js
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports", "mod", "mod"], factory);
}
})(function (require, exports) {
"use strict";
var __syncRequire = typeof module === "object" && typeof module.exports === "object";
Object.defineProperty(exports, "__esModule", { value: true });
exports.e1 = void 0;
const mod_1 = require("mod");
const mod = require("mod");
const dynamic = __syncRequire ? Promise.resolve().then(() => require("mod")) : new Promise((resolve_1, reject_1) => { require(["mod"], resolve_1, reject_1); });
console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);
exports.e1 = 0;
exports.default = "default export";
});
moduleResolution 컴파일러 옵션
이 섹션은 여러 moduleResolution 모드가 공유하는 모듈 해석 기능과 과정을 설명한 뒤, 각 모드의 세부 사항을 다룹니다. 옵션이 무엇이고 전체 컴파일 과정에서 어떻게 맞물리는지에 대한 배경은 모듈 해석 이론 섹션을 참고하세요. 간단히 말해, moduleResolution은 TypeScript가 모듈 지정자(import/export/require 문의 문자열 리터럴)를 디스크상의 파일로 어떻게 해석하는지 제어하며, 대상 런타임이나 번들러가 쓰는 모듈 해석기에 맞게 설정해야 해요.
공통 기능과 과정
파일 확장자 치환
TypeScript는 항상 내부적으로 타입 정보를 제공할 수 있는 파일로 해석하려 하면서, 런타임이나 번들러가 같은 경로로 JavaScript 구현을 제공하는 파일에 해석할 수 있도록 보장합니다. 지정된 moduleResolution 알고리즘에 따라 런타임이나 번들러에서 JavaScript 파일 조회를 촉발할 어떤 모듈 지정자에 대해서도, TypeScript는 먼저 같은 이름과 유사한 파일 확장자를 가진 TypeScript 구현 파일이나 타입 선언 파일을 찾으려고 합니다.
| 런타임 조회 | TypeScript 조회 #1 | TypeScript 조회 #2 | TypeScript 조회 #3 | TypeScript 조회 #4 | TypeScript 조회 #5 |
|---|---|---|---|---|---|
/mod.js |
/mod.ts |
/mod.tsx |
/mod.d.ts |
/mod.js |
./mod.jsx |
/mod.mjs |
/mod.mts |
/mod.d.mts |
/mod.mjs |
||
/mod.cjs |
/mod.cts |
/mod.d.cts |
/mod.cjs |
이 동작은 import에 실제로 쓴 모듈 지정자와 무관합니다. 즉, 모듈 지정자가 명시적으로 .js 파일 확장자를 쓰더라도 TypeScript는 .ts나 .d.ts 파일로 해석할 수 있어요:
import x from "./mod.js";
// Runtime lookup: "./mod.js"
// TypeScript lookup #1: "./mod.ts"
// TypeScript lookup #2: "./mod.d.ts"
// TypeScript lookup #3: "./mod.js"
TypeScript의 모듈 해석이 이렇게 동작하는 이유는 TypeScript는 호스트의 모듈 해석을 흉내 내되, 타입을 붙여서를 참고하세요.
상대 파일 경로 해석
모든 TypeScript moduleResolution 알고리즘은 파일 확장자를 포함한 상대 경로로 모듈을 참조하는 것을 지원합니다(확장자는 위 규칙에 따라 치환됩니다):
// @Filename: a.ts
export {};
// @Filename: b.ts
import {} from "./a.js"; // ✅ Works in every `moduleResolution`
확장자 없는 상대 경로
어떤 경우 런타임이나 번들러는 상대 경로에서 .js 파일 확장자 생략을 허용합니다. TypeScript는 moduleResolution 설정과 문맥이 런타임이나 번들러가 이를 지원한다는 것을 나타낼 때 이 동작을 지원해요:
// @Filename: a.ts
export {};
// @Filename: b.ts
import {} from "./a";
TypeScript가 모듈 지정자 "./a"에 대해 런타임이 ./a.js 조회를 수행할 것이라고 판단하면, ./a.js는 확장자 치환을 거쳐 이 예시에서 파일 a.ts로 해석됩니다.
확장자 없는 상대 경로는 Node.js의 import 경로에서 지원되지 않으며, package.json 파일에 지정된 파일 경로에서도 항상 지원되지는 않아요. TypeScript는 일부 런타임과 번들러가 그렇게 하더라도 현재 .mjs/.mts 또는 .cjs/.cts 파일 확장자 생략을 지원하지 않습니다.
디렉터리 모듈 (index 파일 해석)
어떤 경우 파일이 아니라 디렉터리가 모듈로 참조될 수 있어요. 가장 단순하고 흔한 경우는 런타임이나 번들러가 디렉터리에서 index.js 파일을 찾는 것입니다. TypeScript는 moduleResolution 설정과 문맥이 런타임이나 번들러가 이를 지원한다는 것을 나타낼 때 이 동작을 지원해요:
// @Filename: dir/index.ts
export {};
// @Filename: b.ts
import {} from "./dir";
TypeScript가 모듈 지정자 "./dir"에 대해 런타임이 ./dir/index.js 조회를 수행할 것이라고 판단하면, ./dir/index.js는 확장자 치환을 거쳐 이 예시에서 파일 dir/index.ts로 해석됩니다.
디렉터리 모듈은 package.json 파일도 포함할 수 있는데, "main"과 "types" 필드의 해석이 지원되고 index.js 조회보다 우선합니다. "typesVersions" 필드도 디렉터리 모듈에서 지원됩니다.
디렉터리 모듈은 node_modules 패키지와 같지 않으며 패키지에 사용 가능한 기능의 부분집합만 지원하고, 일부 문맥에서는 전혀 지원되지 않는다는 점에 주의하세요. Node.js는 이를 레거시 기능으로 간주합니다.
paths
개요
TypeScript는 paths 컴파일러 옵션으로 bare 지정자에 대한 컴파일러의 모듈 해석을 재정의하는 방법을 제공합니다. 이 기능은 원래 AMD 모듈 로더(ESM이 존재하거나 번들러가 널리 쓰이기 전에 브라우저에서 모듈을 실행하는 수단)와 함께 쓰도록 설계됐지만, 오늘날에도 런타임이나 번들러가 TypeScript가 모델링하지 않는 모듈 해석 기능을 지원할 때 쓰임새가 있어요. 예를 들어 --experimental-network-imports로 Node.js를 실행할 때, 특정 https:// import에 대한 로컬 타입 정의 파일을 수동으로 지정할 수 있습니다:
{
"compilerOptions": {
"module": "nodenext",
"paths": {
"https://esm.sh/[email protected]": ["./node_modules/@types/lodash/index.d.ts"]
}
}
}
// Typed by ./node_modules/@types/lodash/index.d.ts due to `paths` entry
import { add } from "https://esm.sh/[email protected]";
번들러로 빌드하는 앱이 번들러 설정에 편의 경로 별칭을 정의하고, 그 별칭을 paths로 TypeScript에 알려주는 것도 흔해요:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@app/*": ["./src/*"]
}
}
}
paths는 방출에 영향을 주지 않음
paths 옵션은 TypeScript가 방출하는 코드의 import 경로를 바꾸지 않습니다. 결과적으로 TypeScript에서는 동작하는 것처럼 보이지만 런타임에서는 크래시가 나는 경로 별칭을 만들기 매우 쉬워요:
{
"compilerOptions": {
"module": "nodenext",
"paths": {
"node-has-no-idea-what-this-is": ["./oops.ts"]
}
}
}
// TypeScript: ✅
// Node.js: 💥
import {} from "node-has-no-idea-what-this-is";
번들링된 앱이 paths를 설정하는 건 괜찮지만, 게시된 라이브러리는 하지 않는 것이 아주 중요해요. 방출된 JavaScript가 그 사용자들이 TypeScript와 번들러 둘 다에 같은 별칭을 설정하지 않는 한 라이브러리 소비자에게 동작하지 않기 때문입니다. 라이브러리와 앱 모두 package.json "imports"를 편의 paths 별칭의 표준 대체물로 고려할 수 있어요.
paths는 monorepo 패키지나 node_modules 패키지를 가리켜선 안 됨
paths 별칭과 일치하는 모듈 지정자는 bare 지정자이지만, 별칭이 해석되면 모듈 해석은 해석된 경로를 상대 경로로 진행합니다. 결과적으로 node_modules 패키지 조회에서 일어나는 해석 기능들, package.json "exports" 필드 지원을 포함해서, paths 별칭이 일치하면 적용되지 않아요. paths를 node_modules 패키지를 가리키는 데 쓰면 놀라운 동작이 생길 수 있습니다:
{
"compilerOptions": {
"paths": {
"pkg": ["./node_modules/pkg/dist/index.d.ts"],
"pkg/*": ["./node_modules/pkg/*"]
}
}
}
이 설정이 패키지 해석의 일부 동작을 흉내 낼 수는 있지만, 패키지의 package.json 파일이 정의하는 main, types, exports, typesVersions를 모두 재정의하고, 패키지에서의 import가 런타임에서 실패할 수 있어요.
같은 주의 사항이 monorepo에서 서로를 참조하는 패키지에도 적용됩니다. paths로 TypeScript가 "@my-scope/lib"을 형제 패키지로 인위적으로 해석하게 하는 대신, npm, yarn, pnpm의 워크스페이스를 사용해 패키지를 node_modules에 심볼릭 링크해서, TypeScript와 런타임·번들러 모두 실제 node_modules 패키지 조회를 수행하게 하는 게 가장 좋아요. 특히 monorepo 패키지를 npm에 게시할 예정이라면 중요합니다. 사용자가 설치하면 패키지가 node_modules 패키지 조회로 서로를 참조하게 되고, 워크스페이스를 쓰면 로컬 개발 중에 그 동작을 테스트할 수 있으니까요.
baseUrl과의 관계
baseUrl이 제공되면 각 paths 배열의 값은 baseUrl에 상대적으로 해석됩니다. 그렇지 않으면 그것을 정의한 tsconfig.json 파일에 상대적으로 해석돼요.
와일드카드 치환
paths 패턴은 * 와일드카드 하나를 포함할 수 있는데, 어떤 문자열과도 매칭됩니다. 그 * 토큰은 파일 경로 값에서 매칭된 문자열을 치환하는 데 쓸 수 있어요:
{
"compilerOptions": {
"paths": {
"@app/*": ["./src/*"]
}
}
}
"@app/components/Button"의 import를 해석할 때 TypeScript는 @app/*에 매칭시켜 *를 components/Button에 바인딩한 뒤, tsconfig.json 경로에 상대적으로 ./src/components/Button 경로 해석을 시도합니다. 이 조회의 나머지는 moduleResolution 설정에 따라 다른 상대 경로 조회와 같은 규칙을 따릅니다.
여러 패턴이 모듈 지정자와 일치할 때는, * 토큰 앞의 매칭 접두사가 가장 긴 패턴이 사용됩니다:
{
"compilerOptions": {
"paths": {
"*": ["./src/foo/one.ts"],
"foo/*": ["./src/foo/two.ts"],
"foo/bar": ["./src/foo/three.ts"]
}
}
}
"foo/bar"의 import를 해석할 때 세 paths 패턴 모두 일치하지만, "foo/bar"가 "foo/"와 ""보다 길므로 마지막 것이 사용됩니다.
폴백
하나의 경로 매핑에 여러 파일 경로를 제공할 수 있어요. 한 경로의 해석이 실패하면 배열의 다음 것이 시도되며, 성공하거나 배열 끝에 도달할 때까지 계속됩니다:
{
"compilerOptions": {
"paths": {
"*": ["./vendor/*", "./types/*"]
}
}
}
baseUrl
baseUrl은 AMD 모듈 로더와 함께 쓰도록 설계됐어요. AMD 모듈 로더를 쓰지 않는다면 아마baseUrl도 쓰지 말아야 합니다. TypeScript 4.1부터baseUrl은 더 이상paths를 쓰는 데 필요하지 않으며,paths값이 해석되는 디렉터리를 설정하기 위해 쓰지 말아야 합니다.
baseUrl 컴파일러 옵션은 어떤 moduleResolution 모드와도 결합할 수 있고, bare 지정자(./, ../, /로 시작하지 않는 모듈 지정자)가 해석되는 디렉터리를 지정합니다. baseUrl은 이를 지원하는 moduleResolution 모드에서 node_modules 패키지 조회보다 높은 우선순위를 가져요.
baseUrl 조회를 수행할 때 해석은 다른 상대 경로 해석과 같은 규칙으로 진행됩니다. 예를 들어 확장자 없는 상대 경로를 지원하는 moduleResolution 모드에서, baseUrl이 /src로 설정되어 있다면 모듈 지정자 "some-file"은 /src/some-file.ts로 해석될 수 있어요.
상대 모듈 지정자의 해석은 baseUrl 옵션의 영향을 절대 받지 않습니다.
node_modules 패키지 조회
Node.js는 상대 경로, 절대 경로, URL이 아닌 모듈 지정자를 node_modules 하위 디렉터리에서 조회하는 패키지에 대한 참조로 취급합니다. 번들러는 사용자들이 Node.js에서 쓰던 것과 같은 의존성 관리 시스템, 심지어 같은 의존성을 쓸 수 있게 이 동작을 편리하게 채택했어요. TypeScript의 moduleResolution 옵션 중 classic을 제외한 모든 것이 node_modules 조회를 지원합니다. (classic은 다른 해석 수단이 실패할 때 node_modules/@types에서 조회를 지원하지만, node_modules에서 패키지를 직접 찾지는 않아요.) 모든 node_modules 패키지 조회는 다음 구조를 가집니다(paths, baseUrl, self-name import, package.json "imports" 조회처럼 우선순위가 높은 bare 지정자 규칙이 모두 소진된 뒤 시작합니다):
- import하는 파일의 각 조상 디렉터리에 대해, 그 안에
node_modules디렉터리가 존재하면:node_modules안에 패키지와 같은 이름의 디렉터리가 있으면:- 패키지 디렉터리에서 타입 해석 시도.
- 결과를 찾으면 반환하고 검색 중단.
node_modules/@types안에 패키지와 같은 이름의 디렉터리가 있으면:@types패키지 디렉터리에서 타입 해석 시도.- 결과를 찾으면 반환하고 검색 중단.
- 모든
node_modules디렉터리를 통해 이전 검색을 반복하되, 이번에는 JavaScript 파일도 결과로 허용하고@types디렉터리에서는 검색하지 않음.
모든 moduleResolution 모드(classic 제외)는 이 패턴을 따르며, 일단 패키지 디렉터리를 찾은 뒤 그 디렉터리에서 어떻게 해석할지에 대한 세부 사항은 다르며, 다음 섹션들에서 설명합니다.
package.json "exports"
moduleResolution이 node16, nodenext, bundler로 설정되고 resolvePackageJsonExports가 비활성화되어 있지 않으면, TypeScript는 bare 지정자 node_modules 패키지 조회로 촉발된 패키지 디렉터리에서 해석할 때 Node.js의 package.json "exports" 명세를 따릅니다.
TypeScript가 "exports"를 통해 모듈 지정자를 파일 경로로 해석하는 구현은 Node.js를 정확히 따릅니다. 그러나 일단 파일 경로가 해석되면, TypeScript는 여전히 타입을 찾는 것을 우선시해 여러 파일 확장자를 시도합니다.
조건부 "exports"를 통해 해석할 때 TypeScript는 항상 "types"와 "default" 조건이 있으면 매칭시킵니다. 추가로 "types@{selector}" 형태의 버전 붙은 types 조건({selector}는 "typesVersions"-호환 버전 선택자)을 "typesVersions"에 구현된 버전 매칭 규칙에 따라 매칭시킵니다. 다른 비-구성 가능 조건들은 moduleResolution 모드에 의존하며 다음 섹션들에서 지정합니다. customConditions 컴파일러 옵션으로 추가 조건을 매칭하도록 구성할 수 있어요.
"exports"의 존재는 "exports"에 명시적으로 나열되거나 패턴으로 매칭되지 않은 어떤 서브패스의 해석도 차단한다는 점에 주의하세요.
예시: 서브패스, 조건, 확장자 치환
시나리오: 조건 ["types", "node", "require"](moduleResolution 설정과 모듈 해석 요청을 촉발한 문맥이 결정)로 "pkg/subpath"가 요청된, 다음 package.json을 가진 패키지 디렉터리:
{
"name": "pkg",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
},
"./subpath": {
"import": "./subpath/index.mjs",
"require": "./subpath/index.cjs"
}
}
}
패키지 디렉터리 내 해석 과정:
"exports"가 존재하나요? 예."exports"에"./subpath"항목이 있나요? 예.exports["./subpath"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"import"가 이 요청과 매칭되나요? 아니요. - 두 번째 조건
"require"가 이 요청과 매칭되나요? 예. - 경로
"./subpath/index.cjs"가 인식되는 TypeScript 파일 확장자를 가졌나요? 아니요, 그래서 확장자 치환을 사용합니다. - 확장자 치환으로 다음 경로를 시도하고, 존재하는 첫 번째 것을 반환하거나 그렇지 않으면
undefined반환:./subpath/index.cts./subpath/index.d.cts./subpath/index.cjs
./subpath/index.cts나 ./subpath.d.cts가 존재하면 해석이 완료됩니다. 그렇지 않으면 해석이 node_modules 패키지 조회 규칙에 따라 node_modules/@types/pkg와 다른 node_modules 디렉터리에서 타입 해석을 시도합니다. 타입이 없으면 모든 node_modules를 통한 두 번째 통과가 ./subpath/index.cjs(존재한다고 가정)로 해석되는데, 이는 성공적인 해석으로 간주되지만 타입을 제공하지 않아 any 타입 import가 생기고, noImplicitAny가 켜져 있으면 오류가 납니다.
예시: 명시적 "types" 조건
시나리오: 조건 ["types", "node", "import"]로 "pkg/subpath"가 요청된, 다음 package.json을 가진 패키지 디렉터리:
{
"name": "pkg",
"exports": {
"./subpath": {
"import": {
"types": "./types/subpath/index.d.mts",
"default": "./es/subpath/index.mjs"
},
"require": {
"types": "./types/subpath/index.d.cts",
"default": "./cjs/subpath/index.cjs"
}
}
}
}
패키지 디렉터리 내 해석 과정:
"exports"가 존재하나요? 예."exports"에"./subpath"항목이 있나요? 예.exports["./subpath"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"import"가 이 요청과 매칭되나요? 예. exports["./subpath"].import의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"types"가 이 요청과 매칭되나요? 예. - 경로
"./types/subpath/index.d.mts"가 인식되는 TypeScript 파일 확장자를 가졌나요? 예, 그래서 확장자 치환을 사용하지 않습니다. - 파일이 존재하면 경로
"./types/subpath/index.d.mts"를 반환하고, 그렇지 않으면undefined.
예시: 버전 붙은 "types" 조건
시나리오: TypeScript 4.7.5 사용, 조건 ["types", "node", "import"]로 "pkg/subpath"가 요청된, 다음 package.json을 가진 패키지 디렉터리:
{
"name": "pkg",
"exports": {
"./subpath": {
"types@>=5.2": "./ts5.2/subpath/index.d.ts",
"types@>=4.6": "./ts4.6/subpath/index.d.ts",
"types": "./tsold/subpath/index.d.ts",
"default": "./dist/subpath/index.js"
}
}
}
패키지 디렉터리 내 해석 과정:
"exports"가 존재하나요? 예."exports"에"./subpath"항목이 있나요? 예.exports["./subpath"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"types@>=5.2"가 이 요청과 매칭되나요? 아니요, 4.7.5는 5.2보다 크거나 같지 않습니다. - 두 번째 조건
"types@>=4.6"가 이 요청과 매칭되나요? 예, 4.7.5는 4.6보다 크거나 같습니다. - 경로
"./ts4.6/subpath/index.d.ts"가 인식되는 TypeScript 파일 확장자를 가졌나요? 예, 그래서 확장자 치환을 사용하지 않습니다. - 파일이 존재하면 경로
"./ts4.6/subpath/index.d.ts"를 반환하고, 그렇지 않으면undefined.
예시: 서브패스 패턴
시나리오: 조건 ["types", "node", "import"]로 "pkg/wildcard.js"가 요청된, 다음 package.json을 가진 패키지 디렉터리:
{
"name": "pkg",
"type": "module",
"exports": {
"./*.js": {
"types": "./types/*.d.ts",
"default": "./dist/*.js"
}
}
}
패키지 디렉터리 내 해석 과정:
"exports"가 존재하나요? 예."exports"에"./wildcard.js"항목이 있나요? 아니요.*가 있는 어떤 키가"./wildcard.js"와 매칭되나요? 예,"./*.js"가 매칭되고wildcard를 치환값으로 설정합니다.exports["./*.js"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"types"가 이 요청과 매칭되나요? 예. ./types/*.d.ts에서*를 치환값wildcard로 교체합니다../types/wildcard.d.ts- 경로
"./types/wildcard.d.ts"가 인식되는 TypeScript 파일 확장자를 가졌나요? 예, 그래서 확장자 치환을 사용하지 않습니다. - 파일이 존재하면 경로
"./types/wildcard.d.ts"를 반환하고, 그렇지 않으면undefined.
예시: "exports"가 다른 서브패스를 차단
시나리오: 다음 package.json을 가진 패키지 디렉터리에서 "pkg/dist/index.js"가 요청됨:
{
"name": "pkg",
"main": "./dist/index.js",
"exports": "./dist/index.js"
}
패키지 디렉터리 내 해석 과정:
"exports"가 존재하나요? 예.exports의 값은 문자열입니다. 패키지 루트(".")의 파일 경로여야 합니다.- 요청
"pkg/dist/index.js"가 패키지 루트용인가요? 아니요,dist/index.js라는 서브패스가 있습니다. - 해석 실패;
undefined반환.
"exports"가 없다면 요청은 성공할 수 있었지만, "exports"의 존재는 "exports"를 통해 매칭할 수 없는 어떤 서브패스의 해석도 차단합니다.
package.json "typesVersions"
node_modules 패키지나 디렉터리 모듈은 package.json에 "typesVersions" 필드를 지정해서, TypeScript 컴파일러 버전에 따라, 그리고 node_modules 패키지의 경우 해석 중인 서브패스에 따라 TypeScript의 해석 과정을 리다이렉트할 수 있어요. 이를 통해 패키지 작성자는 한 타입 정의 집합에 새 TypeScript 문법을 포함하면서, downlevel-dts 같은 도구로 이전 TypeScript 버전과의 하위 호환성을 위한 다른 집합을 제공할 수 있어요. "typesVersions"는 모든 moduleResolution 모드에서 지원되지만, package.json "exports"가 읽히는 상황에서는 이 필드가 읽히지 않아요.
예시: 모든 요청을 하위 디렉터리로 리다이렉트
시나리오: 모듈이 TypeScript 5.2로 "pkg"를 import하는데, node_modules/pkg/package.json이:
{
"name": "pkg",
"version": "1.0.0",
"types": "./index.d.ts",
"typesVersions": {
">=3.1": {
"*": ["ts3.1/*"]
}
}
}
해석 과정:
- (컴파일러 옵션에 따라)
"exports"가 존재하나요? 아니요. "typesVersions"가 존재하나요? 예.- TypeScript 버전이
>=3.1인가요? 예. 매핑"*": ["ts3.1/*"]을 기억하세요. - 패키지 이름 이후의 서브패스를 해석 중인가요? 아니요, 루트
"pkg"만. "types"가 존재하나요? 예."typesVersions"의 어떤 키가./index.d.ts와 매칭되나요? 예,"*"가 매칭되고index.d.ts를 치환값으로 설정합니다.ts3.1/*에서*를 치환값./index.d.ts로 교체합니다:ts3.1/index.d.ts.- 경로
./ts3.1/index.d.ts가 인식되는 TypeScript 파일 확장자를 가졌나요? 예, 그래서 확장자 치환을 사용하지 않습니다. - 파일이 존재하면 경로
./ts3.1/index.d.ts를 반환하고, 그렇지 않으면undefined.
예시: 특정 파일에 대한 요청 리다이렉트
시나리오: 모듈이 TypeScript 3.9로 "pkg"를 import하는데, node_modules/pkg/package.json이:
{
"name": "pkg",
"version": "1.0.0",
"types": "./index.d.ts",
"typesVersions": {
"<4.0": { "index.d.ts": ["index.v3.d.ts"] }
}
}
해석 과정:
- (컴파일러 옵션에 따라)
"exports"가 존재하나요? 아니요. "typesVersions"가 존재하나요? 예.- TypeScript 버전이
<4.0인가요? 예. 매핑"index.d.ts": ["index.v3.d.ts"]을 기억하세요. - 패키지 이름 이후의 서브패스를 해석 중인가요? 아니요, 루트
"pkg"만. "types"가 존재하나요? 예."typesVersions"의 어떤 키가./index.d.ts와 매칭되나요? 예,"index.d.ts"가 매칭됩니다.- 경로
./index.v3.d.ts가 인식되는 TypeScript 파일 확장자를 가졌나요? 예, 그래서 확장자 치환을 사용하지 않습니다. - 파일이 존재하면 경로
./index.v3.d.ts를 반환하고, 그렇지 않으면undefined.
package.json "main"과 "types"
디렉터리의 package.json "exports" 필드가 읽히지 않고(컴파일러 옵션 때문이든, 존재하지 않기 때문이든, 디렉터리가 node_modules 패키지가 아니라 디렉터리 모듈로 해석되기 때문이든), 모듈 지정자가 패키지 이름이나 package.json 포함 디렉터리 이후에 서브패스가 없으면, TypeScript는 패키지나 디렉터리의 메인 모듈을 찾기 위해 이 package.json 필드들에서 순서대로 해석을 시도합니다:
"types""typings"(레거시)"main"
"types"에서 찾은 선언 파일은 "main"에서 찾은 구현 파일의 정확한 표현이라고 가정됩니다. "types"와 "typings"가 없거나 해석할 수 없으면 TypeScript는 "main" 필드를 읽고 확장자 치환을 수행해 선언 파일을 찾습니다.
타입 있는 패키지를 npm에 게시할 때는 확장자 치환이나 package.json "exports"가 불필요하게 만들더라도 "types" 필드를 포함하는 것을 권장해요. npm이 package.json에 "types" 필드가 있을 때만 패키지 레지스트리 목록에 TS 아이콘을 표시하기 때문입니다.
패키지 상대 파일 경로
package.json "exports"도 package.json "typesVersions"도 적용되지 않으면, bare 패키지 지정자의 서브패스는 해당 상대 경로 해석 규칙에 따라 패키지 디렉터리에 상대적으로 해석됩니다. [package.json "exports"]를 존중하는 모드에서는, 위의 예시에서 보여줬듯 import가 "exports"를 통해 해석에 실패하더라도, 패키지의 package.json에 "exports" 필드가 존재하기만 해도 이 동작이 차단됩니다. 반면 import가 "typesVersions"를 통해 해석에 실패하면 폴백으로 패키지 상대 파일 경로 해석이 시도됩니다.
패키지 상대 경로가 지원될 때, 그것은 moduleResolution 모드와 문맥을 고려해 다른 상대 경로와 같은 규칙으로 해석됩니다. 예를 들어 --moduleResolution nodenext에서는 디렉터리 모듈과 확장자 없는 경로가 import가 아닌 require 호출에서만 지원됩니다:
// @Filename: module.mts
import "pkg/dist/foo"; // ❌ import, needs `.js` extension
import "pkg/dist/foo.js"; // ✅
import foo = require("pkg/dist/foo"); // ✅ require, no extension needed
package.json "imports"와 self-name import
moduleResolution이 node16, nodenext, bundler로 설정되고 resolvePackageJsonImports가 비활성화되지 않았으면, TypeScript는 #로 시작하는 import 경로를 import하는 파일의 가장 가까운 조상 package.json의 "imports" 필드를 통해 해석하려고 합니다. 마찬가지로 package.json "exports" 조회가 켜져 있으면, TypeScript는 현재 패키지 이름(import하는 파일의 가장 가까운 조상 package.json의 "name" 필드 값)으로 시작하는 import 경로를 그 package.json의 "exports" 필드를 통해 해석하려고 합니다. 이 두 기능 모두 패키지의 파일들이 상대 import 경로를 대신해 같은 패키지 안의 다른 파일을 import할 수 있게 해줍니다.
TypeScript는 파일 경로가 해석될 때까지 Node.js의 "imports" 및 self reference 해석 알고리즘을 정확히 따릅니다. 그 지점에서 TypeScript의 해석 알고리즘은, 해석되는 "imports"나 "exports"를 포함한 package.json이 node_modules 의존성에 속하는지, 아니면 컴파일 중인 로컬 프로젝트(즉 그 디렉터리가 import하는 파일을 포함한 프로젝트의 tsconfig.json 파일을 포함하는지)에 속하는지에 따라 갈라집니다:
- package.json이
node_modules에 있으면, TypeScript는 파일 경로에 인식되는 TypeScript 파일 확장자가 없다면 확장자 치환을 적용하고 결과 파일 경로의 존재를 확인합니다. - package.json이 로컬 프로젝트의 일부라면,
"imports"에서 해석된 출력 JavaScript나 선언 파일 경로를 결국 만들 입력 TypeScript 구현 파일을 찾기 위해 추가 재매핑 단계가 수행됩니다. 이 단계가 없으면"imports"경로를 해석하는 어떤 컴파일도, 현재 컴파일에 포함될 다른 입력 파일 대신 이전 컴파일의 출력 파일을 참조하게 됩니다. 이 재매핑은 tsconfig.json의outDir/declarationDir과rootDir을 사용하므로,"imports"를 쓰려면 보통 명시적rootDir설정이 필요해요.
이 변형 덕분에 패키지 작성자는 npm에 게시될 컴파일 출력만 참조하는 "imports"와 "exports" 필드를 쓸 수 있으면서도, 로컬 개발에서는 원래 TypeScript 소스 파일을 계속 쓸 수 있어요.
예시: 조건이 있는 로컬 프로젝트
시나리오: 조건 ["types", "node", "import"]로 "/src/main.mts"가 "#utils"를 import하는데, tsconfig.json과 package.json이 있는 프로젝트 디렉터리:
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node16",
"resolvePackageJsonImports": true,
"rootDir": "./src",
"outDir": "./dist"
}
}
// package.json
{
"name": "pkg",
"imports": {
"#utils": {
"import": "./dist/utils.d.mts",
"require": "./dist/utils.d.cts"
}
}
}
해석 과정:
- import 경로가
#로 시작하므로"imports"를 통해 해석을 시도합니다. - 가장 가까운 조상 package.json에
"imports"가 존재하나요? 예. "imports"객체에"#utils"가 존재하나요? 예.imports["#utils"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"import"가 이 요청과 매칭되나요? 예. - 출력 경로를 입력 경로로 매핑하려고 시도해야 하나요? 예, 왜냐하면:
- package.json이
node_modules에 있나요? 아니요, 로컬 프로젝트에 있습니다. - tsconfig.json이 package.json 디렉터리 안에 있나요? 예.
- package.json이
./dist/utils.d.mts에서outDir접두사를rootDir로 교체합니다../src/utils.d.mts- 출력 확장자
.d.mts를 대응 입력 확장자.mts로 교체합니다../src/utils.mts - 파일이 존재하면 경로
"./src/utils.mts"를 반환합니다. - 그렇지 않으면 파일이 존재하면 경로
"./dist/utils.d.mts"를 반환합니다.
예시: 서브패스 패턴이 있는 node_modules 의존성
시나리오: 조건 ["types", "node", "import"]로 "/node_modules/pkg/main.mts"가 "#internal/utils"를 import하는데, 다음 package.json 사용:
// /node_modules/pkg/package.json
{
"name": "pkg",
"imports": {
"#internal/*": {
"import": "./dist/internal/*.mjs",
"require": "./dist/internal/*.cjs"
}
}
}
해석 과정:
- import 경로가
#로 시작하므로"imports"를 통해 해석을 시도합니다. - 가장 가까운 조상 package.json에
"imports"가 존재하나요? 예. "imports"객체에"#internal/utils"가 존재하나요? 아니요, 패턴 매칭을 확인합니다.*가 있는 어떤 키가"#internal/utils"와 매칭되나요? 예,"#internal/*"가 매칭되고utils를 치환값으로 설정합니다.imports["#internal/*"]의 값은 객체입니다. 조건을 지정하고 있을 거예요.- 첫 조건
"import"가 이 요청과 매칭되나요? 예. - 출력 경로를 입력 경로로 매핑하려고 시도해야 하나요? 아니요, package.json이
node_modules에 있기 때문입니다. ./dist/internal/*.mjs에서*를 치환값utils로 교체합니다../dist/internal/utils.mjs- 경로
./dist/internal/utils.mjs가 인식되는 TypeScript 파일 확장자를 가졌나요? 아니요, 확장자 치환을 시도합니다. - 확장자 치환으로 다음 경로를 시도하고, 존재하는 첫 번째 것을 반환하거나 그렇지 않으면
undefined:./dist/internal/utils.mts./dist/internal/utils.d.mts./dist/internal/utils.mjs
node16, nodenext
이 모드들은 Node.js v12 이상의 모듈 해석 동작을 반영합니다. (node16과 nodenext는 현재 동일하지만, 미래에 Node.js가 모듈 시스템에 큰 변화를 주면 node16은 동결되고 nodenext는 새 동작을 반영하도록 갱신됩니다.) Node.js에서 ECMAScript import의 해석 알고리즘은 CommonJS require 호출의 알고리즘과 크게 다릅니다. 해석되는 각 모듈 지정자에 대해, 문법과 import하는 파일의 모듈 형식을 먼저 사용해 그 모듈 지정자가 방출된 JavaScript에서 import인지 require인지 결정합니다. 그 정보는 모듈 해석기에 전달되어 어떤 해석 알고리즘을 쓸지(그리고 package.json "exports"나 "imports"에 "import" 조건을 쓸지 "require" 조건을 쓸지) 결정합니다.
기본적으로 CommonJS 형식으로 결정된 TypeScript 파일은 여전히
import와export문법을 쓸 수 있지만, 방출된 JavaScript는 대신require와module.exports를 사용합니다. 이는require알고리즘으로 해석되는import문을 흔히 볼 수 있다는 뜻이에요. 혼란을 일으킨다면verbatimModuleSyntax컴파일러 옵션을 켜면 되는데, 이는require호출로 방출될import문의 사용을 금지합니다.
동적 import() 호출은 Node.js 동작에 따라 항상 import 알고리즘으로 해석된다는 점에 주의하세요. 그러나 import() 타입은 기존 CommonJS 형식 타입 선언과의 하위 호환성을 위해 import하는 파일의 형식에 따라 해석됩니다:
// @Filename: module.mts
import x from "./mod.js"; // `import` algorithm due to file format (emitted as-written)
import("./mod.js"); // `import` algorithm due to syntax (emitted as-written)
type Mod = typeof import("./mod.js"); // `import` algorithm due to file format
import mod = require("./mod"); // `require` algorithm due to syntax (emitted as `require`)
// @Filename: commonjs.cts
import x from "./mod"; // `require` algorithm due to file format (emitted as `require`)
import("./mod.js"); // `import` algorithm due to syntax (emitted as-written)
type Mod = typeof import("./mod"); // `require` algorithm due to file format
import mod = require("./mod"); // `require` algorithm due to syntax (emitted as `require`)
암시되고 강제되는 옵션
--moduleResolution node16와nodenext는--module node16,node18,node20,nodenext와 짝을 이루어야 합니다.
지원되는 기능
기능은 우선순위 순서로 나열합니다.
import |
require |
|
|---|---|---|
paths |
✅ | ✅ |
baseUrl |
✅ | ✅ |
node_modules 패키지 조회 |
✅ | ✅ |
package.json "exports" |
✅ types, node, import 매칭 |
✅ types, node, require 매칭 |
package.json "imports" 및 self-name import |
✅ types, node, import 매칭 |
✅ types, node, require 매칭 |
package.json "typesVersions" |
✅ | ✅ |
| 패키지 상대 경로 | ✅ exports가 없을 때 |
✅ exports가 없을 때 |
| 전체 상대 경로 | ✅ | ✅ |
| 확장자 없는 상대 경로 | ❌ | ✅ |
| 디렉터리 모듈 | ❌ | ✅ |
bundler
--moduleResolution bundler는 대부분의 JavaScript 번들러에 공통적인 모듈 해석 동작을 모델링하려고 합니다. 간단히 말해, node_modules 조회, 디렉터리 모듈, 확장자 없는 경로처럼 전통적으로 Node.js CommonJS require 해석 알고리즘과 연관된 모든 동작을 지원하면서, package.json "exports"와 package.json "imports" 같은 더 새로운 Node.js 해석 기능도 지원한다는 뜻입니다.
--moduleResolution bundler와 --moduleResolution nodenext의 유사점과 차이점을 생각해 보는 게 도움이 돼요. 특히 package.json "exports"나 "imports"를 해석할 때 무슨 조건을 사용할지 결정하는 방식에서요. .ts 파일의 import 문을 고려해 보세요:
// index.ts
import { foo } from "pkg";
--module nodenext --moduleResolution nodenext에서는 --module 설정이 먼저 import가 .js 파일에서 import로 방출될지 require 호출로 방출될지 결정하고, 그 정보를 TypeScript의 모듈 해석기에 전달하며, 해석기는 그에 맞춰 "pkg"의 package.json "exports"에서 "import" 조건을 매칭할지 "require" 조건을 매칭할지 결정한다는 것을 기억하세요. 이 파일의 스코프에 package.json이 없다고 가정해 보겠습니다. 파일 확장자는 .ts이므로 출력 파일 확장자는 .js가 되고, Node.js는 이를 CommonJS로 해석하므로 TypeScript는 이 import를 require 호출로 방출합니다. 그래서 모듈 해석기는 "pkg"에서 "exports"를 해석할 때 require 조건을 사용합니다.
--moduleResolution bundler에서도 같은 과정이 일어나지만, 이 import 문에 대해 import를 방출할지 require 호출을 방출할지 결정하는 규칙은 달라요. --moduleResolution bundler는 --module esnext나 --module preserve를 필요로 하기 때문입니다. 두 모드 모두에서 ESM import 선언은 항상 ESM import 선언으로 방출되므로, TypeScript의 모듈 해석기는 그 정보를 받아 "pkg"에서 "exports"를 해석할 때 "import" 조건을 사용합니다.
이 설명은 다소 직관에 반할 수 있는데, --moduleResolution bundler는 보통 --noEmit과 함께 쓰이기 때문이에요. 번들러는 보통 원시 .ts 파일을 처리하고 변환되지 않은 import나 require에 대해 모듈 해석을 수행하니까요. 하지만 일관성을 위해 TypeScript는 여전히 module이 결정한 가상의 방출을 사용해 모듈 해석과 타입체크를 안내합니다. 따라서 런타임이나 번들러가 원시 .ts 파일에 대해 동작할 때는 변환을 암시하지 않는 --module preserve가 최선의 선택입니다. --module preserve --moduleResolution bundler 아래에서는 같은 파일에서 각각 import 조건과 require 조건으로 해석될 import와 require를 함께 쓸 수 있어요:
// index.ts
import pkg1 from "pkg"; // Resolved with "import" condition
import pkg2 = require("pkg"); // Resolved with "require" condition
암시되고 강제되는 옵션
--moduleResolution bundler는--module esnext또는--module preserve와 짝을 이루어야 합니다.--moduleResolution bundler는--allowSyntheticDefaultImports를 암시합니다.
지원되는 기능
paths✅baseUrl✅node_modules패키지 조회 ✅- package.json
"exports"✅ 문법에 따라types,import/require매칭 - package.json
"imports"및 self-name import ✅ 문법에 따라types,import/require매칭 - package.json
"typesVersions"✅ - 패키지 상대 경로 ✅
exports가 없을 때 - 전체 상대 경로 ✅
- 확장자 없는 상대 경로 ✅
- 디렉터리 모듈 ✅
node10 (이전의 node)
--moduleResolution node는 TypeScript 5.0에서 node10으로 이름이 바뀌었습니다(하위 호환성을 위해 node는 별칭으로 유지). Node.js v12 이전 버전의 CommonJS 모듈 해석 알고리즘을 반영하며, 더 이상 쓰면 안 돼요.
지원되는 기능
paths✅baseUrl✅node_modules패키지 조회 ✅- package.json
"exports"❌ - package.json
"imports"및 self-name import ❌ - package.json
"typesVersions"✅ - 패키지 상대 경로 ✅
- 전체 상대 경로 ✅
- 확장자 없는 상대 경로 ✅
- 디렉터리 모듈 ✅
classic
classic은 쓰지 마세요.