모듈 레퍼런스
모듈 레퍼런스 (Modules Reference)
이 문서는 TypeScript의 모듈 문법과 module·moduleResolution 컴파일러 옵션을 정리한 레퍼런스예요. 코드는 동작 그대로, 옵션은 각각이 무슨 의미인지 하나씩 옆에서 설명드릴게요.
출처: TypeScript 공식문서
본문
모듈 문법 (Module syntax)
TypeScript 컴파일러는 TypeScript 파일과 JavaScript 파일에서 표준 ECMAScript 모듈 문법을 알아보고, JavaScript 파일에서는 다양한 형태의 CommonJS 문법도 인식해요.
그리고 TypeScript 파일과 JSDoc 주석에서 쓸 수 있는, TypeScript만의 문법 확장도 몇 가지 있어요.
TypeScript 전용 선언의 import/export
타입 별칭(type alias), 인터페이스, enum, namespace는 표준 JavaScript 선언과 똑같이 export 수식어를 붙여 모듈에서 내보낼 수 있어요.
// 표준 JavaScript 문법...
export function f() {}
// ...타입 선언으로 확장한 형태
export type SomeType = /* ... */;
export interface SomeInterface { /* ... */ }
이 녀석들은 named export에서도 참조할 수 있고, 표준 JavaScript 선언과 나란히 써도 문제없어요.
export { f, SomeType, SomeInterface };
내보낸 타입(그리고 다른 TypeScript 전용 선언)은 표준 ECMAScript import로 가져올 수 있어요.
import { f, SomeType, SomeInterface } from "./module.js";
namespace import나 export를 쓸 때는, 타입 위치에서 참조하면 내보낸 타입을 namespace에서 사용할 수 있어요.
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 (Type-only imports and exports)
JavaScript로 import/export를 내보낼 때, 기본적으로 TypeScript는 타입 위치에서만 쓰인 import와 타입만 가리키는 export를 자동으로 제거(emit하지 않음)해요. 타입 전용 import/export를 쓰면 이 동작을 강제해서 제거(elision)가 일어난다는 걸 명시적으로 만들 수 있어요. import type으로 작성한 import 선언, export type { ... }으로 작성한 export 선언, 그리고 type 키워드가 앞에 붙은 import/export 지정자(specifier)는 모두 결과 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에는 남지 않으니까 emit되지 않는 위치에서만 쓸 수 있어요.
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와 named 바인딩을 동시에 선언할 수 없어요. type이 default import에 붙는 건지 import 선언 전체에 붙는 건지 애매해지거든요. 이럴 땐 import 선언을 둘로 나누거나, default를 named 바인딩으로 쓰면 돼요.
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는 JavaScript의 동적 import와 비슷한 타입 문법을 제공해서, import 선언을 쓰지 않고도 모듈의 타입을 참조할 수 있어요.
// 내보낸 타입 접근:
type WriteFileOptions = import("fs").WriteFileOptions;
// 내보낸 값의 타입 접근:
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 프로그램에 로드되면, 다른 파일에서 그 선언된 모듈을 import하는 것을 TypeScript가 인식해요.
// 👇 앰비언트 모듈이 로드되도록 보장 -
// path.d.ts가 프로젝트 tsconfig.json에
// 어떻게든 포함되어 있다면 불필요할 수도 있어요.
/// <reference path="path.d.ts" />
import { normalize, join } from "path";
앰비언트 모듈 선언은 모듈 증강(module augmentations)과 문법이 똑같아서 헷갈리기 쉬워요. 이 모듈 선언 문법은 파일이 모듈일 때(최상위 import나 export 문이 있거나, --moduleDetection force 또는 auto의 영향을 받을 때) 모듈 증강이 돼요.
// 더 이상 앰비언트 모듈 선언이 아니에요!
export {};
declare module "path" {
export function normalize(p: string): string;
export function join(...paths: any[]): string;
export var sep: string;
}
앰비언트 모듈은 선언 본문 안에서 다른 모듈을 참조하는 import를 쓸 수 있는데, 그렇게 해도 포함하는 파일이 모듈이 되진 않아요(그랬다면 앰비언트 모듈 선언이 모듈 증강이 되어버리거든요).
declare module "m" {
// 이걸 "m" 밖으로 옮기면 파일 의미가 완전히 달라져요!
import { SomeType } from "other";
export function f(): SomeType;
}
패턴 앰비언트 모듈은 이름에 * 와일드카드 문자 하나를 담는데, import 경로의 0개 이상의 문자와 매칭돼요. 커스텀 로더가 제공하는 모듈을 선언할 때 유용할 수 있어요.
declare module "*.html" {
const content: string;
export default content;
}
module 컴파일러 옵션
이 절에서는 각 module 컴파일러 옵션 값의 세부 사항을 다뤄요. 이 옵션이 무엇이고 전체 컴파일 과정에서 어떤 역할을 하는지 더 깊은 배경이 궁금하다면 모듈 출력 형식(MODULE OUTPUT FORMAT) 이론 절을 보세요. 간단히 말하면, module 컴파일러 옵션은 과거에는 내보내는 JavaScript의 모듈 출력 형식만 제어하는 데 쓰였어요. 하지만 최근의 node16, node18, nodenext 값은 Node.js 모듈 시스템의 다양한 특성을 폭넓게 표현하는데, 어떤 모듈 형식을 지원하는지, 각 파일의 모듈 형식이 어떻게 결정되는지, 서로 다른 모듈 형식이 어떻게 상호 운용되는지까지 다뤄요.
node16, node18, node20, nodenext
Node.js는 CommonJS와 ECMAScript 모듈을 둘 다 지원하는데, 각 파일이 어떤 형식이어야 하는지와 두 형식이 어떻게 상호 운용이 허용되는지에 대한 구체적인 규칙이 있어요. node16, node18, nodenext는 Node.js의 이중 형식(dual-format) 모듈 시스템의 전체 동작을 표현하며, 파일을 CommonJS나 ESM 중 한 형식으로 내보내요. 이건 다른 모든 module 옵션과 다른데, 그 옵션들은 런타임과 무관하고 모든 출력 파일을 단일 형식으로 강제해서, 출력이 자신의 런타임에 유효한지 확인하는 일은 사용자 몫으로 남겨두거든요.
흔한 오해가 하나 있는데, node16—nodenext는 ES 모듈만 내보낸다는 거예요. 실제로 이 모드들은 ES 모듈을 지원하는 Node.js 버전을 표현하긴 하지만, ES 모듈만 쓰는 프로젝트를 뜻하는 건 아니에요. 감지된 각 파일의 모듈 형식에 따라 ESM과 CommonJS 내보내기가 모두 지원돼요. 이 옵션들이 Node.js의 이중 모듈 시스템의 복잡성을 반영하는 유일한 module 옵션이기 때문에, ES 모듈을 쓰든 안 쓰든 Node.js v12 이상에서 실행할 의도가 있는 모든 앱과 라이브러리에 대해 유일하게 올바른 module 옵션이에요.
고정 버전인 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" | ✅ |
모듈 형식 감지 (Module format detection)
.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 모듈로 내보낼 수 있어요.
상호 운용 규칙 (Interoperability rules)
ES 모듈이 CommonJS 모듈을 참조할 때:
- CommonJS 모듈의
module.exports는 ES 모듈에게 default import로 제공돼요. - CommonJS 모듈
module.exports의 (default외의) 속성은 ES 모듈에게 named import로 제공될 수도 있고 아닐 수도 있어요. Node.js는 정적 분석(static analysis)을 통해 제공하려고 시도해요. TypeScript는 선언 파일만으로 그 정적 분석이 성공할지 알 수 없어서, 낙관적으로 성공할 것이라고 가정해요. 이 때문에 런타임에 크래시 날 수 있는 named import를 TypeScript가 잡아내지 못할 수 있어요. 자세한 내용은 #54018을 보세요.
CommonJS 모듈이 ES 모듈을 참조할 때:
node16과node18에서는require가 ES 모듈을 참조할 수 없어요. TypeScript의 경우, CommonJS 모듈로 감지된 파일의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(다른 ES 모듈에서 import * as ns from "./module.js"로 얻을 수 있는 것)의 Promise를 반환해요.
내보내기 (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"; // 변환됨
const dynamic = import("mod"); // 변환 안 됨
// @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")); // 변환됨
const dynamic = import("mod"); // 변환 안 됨
내포·강제되는 옵션 (Implied and enforced options)
--module nodenext는--moduleResolution nodenext를 내포하고 강제해요.--module node18또는node16은--moduleResolution node16을 내포하고 강제해요.--module nodenext는--target esnext를 내포해요.--module node18또는node16은--target es2022를 내포해요.--module nodenext또는node18또는node16은--esModuleInterop을 내포해요.
요약 (Summary)
node16,node18,nodenext는 ES 모듈을 쓰든 안 쓰든, Node.js v12 이상에서 실행할 의도가 있는 모든 앱과 라이브러리에 대해 유일하게 올바른module옵션이에요.node16,node18,nodenext는 감지된 각 파일의 모듈 형식에 따라 CommonJS나 ESM 형식으로 파일을 내보내요.- Node.js의 ESM/CJS 상호 운용 규칙이 타입 검사에 반영돼요.
- 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을 설정하면 TypeScript에 번들러나 런타임이 import/export를 어떻게 처리할지 알려줘서, import된 값에서 보이는 타입이 런타임이나 번들링 후 실제로 일어날 일을 정확히 반영하도록 보장해요. 더 논의는 --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에는
esnext를--moduleResolution bundler와 함께 쓰세요. - 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와 namespace 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 모드가 공유하는 모듈 해석 기능과 과정을 설명하고, 그 다음 각 모드의 세부 사항을 다뤄요. 이 옵션이 무엇이고 전체 컴파일 과정에서 어떤 역할을 하는지 더 깊은 배경이 궁금하다면 모듈 해석(MODULE RESOLUTION) 이론 절을 보세요. 간단히 말하면 moduleResolution은 TypeScript가 모듈 지정자(import/export/require 문의 문자열 리터럴)를 디스크의 파일로 어떻게 해석할지 제어하며, 대상 런타임이나 번들러가 쓰는 모듈 해석기와 일치하도록 설정해야 해요.
공통 기능과 과정 (Common features and processes)
파일 확장자 치환 (File extension substitution)
TypeScript는 항상 내부적으로 타입 정보를 제공할 수 있는 파일로 해석하고 싶어 하면서도, 런타임이나 번들러가 JavaScript 구현을 제공하는 파일로 같은 경로로 해석할 수 있도록 보장해요. moduleResolution 알고리즘에 따라 JavaScript 파일의 조회를 촉발할 모듈 지정자라면, TypeScript는 먼저 같은 이름과 유사한 파일 확장자를 가진 TypeScript 구현 파일이나 타입 선언 파일을 찾으려고 해요.
| Runtime lookup | TypeScript lookup #1 | TypeScript lookup #2 | TypeScript lookup #3 | TypeScript lookup #4 | TypeScript lookup #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";
// 런타임 조회: "./mod.js"
// TypeScript 조회 #1: "./mod.ts"
// TypeScript 조회 #2: "./mod.d.ts"
// TypeScript 조회 #3: "./mod.js"
왜 TypeScript의 모듈 해석이 이렇게 동작하는지에 대한 설명은 TypeScript는 호스트의 모듈 해석을 모방하되 타입을 더한다를 보세요.
상대 파일 경로 해석 (Relative file path resolution)
TypeScript의 모든 moduleResolution 알고리즘은 파일 확장자를 포함한 상대 경로(위 규칙에 따라 치환될)로 모듈을 참조하는 것을 지원해요.
// @Filename: a.ts
export {};
// @Filename: b.ts
import {} from "./a.js"; // ✅ 모든 `moduleResolution`에서 동작
확장자 없는 상대 경로 (Extensionless relative paths)
때로 런타임이나 번들러가 상대 경로에서 .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 파일 확장자를 생략하는 것을 절대 지원하지 않는데, 몇몇 런타임과 번들러는 지원하지만요.
디렉터리 모듈 (Directory modules: 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"]
}
}
}
// `paths` 항목 덕분에 ./node_modules/@types/lodash/index.d.ts로 타입이 지정됨
import { add } from "https://esm.sh/[email protected]";
번들러로 만든 앱이 번들러 설정에 편의용 경로 별칭을 정의하고, 그 별칭을 paths로 TypeScript에 알려주는 것도 흔해요.
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@app/*": ["./src/*"]
}
}
}
paths는 emit에 영향을 주지 않아요
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와 번들러 둘 다에 같은 별칭을 설정하지 않는 한 라이브러리 소비자에게 동작하지 않거든요. 라이브러리와 앱 모두 편의용 paths 별칭의 표준 대체재로 package.json "imports"를 고려할 수 있어요.
paths는 monorepo 패키지나 node_modules 패키지를 가리키면 안 돼요
paths 별칭과 일치하는 모듈 지정자는 bare 지정자이지만, 별칭이 해석되면 이후 모듈 해석은 해석된 경로를 상대 경로로 취급해서 진행돼요. 그래서 package.json "exports" 필드 지원을 포함해 node_modules 패키지 조회에서 일어나는 해석 기능은 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의 workspaces를 써서 패키지를 node_modules에 심링크하는 게 좋아요. 그래야 TypeScript와 런타임 또는 번들러가 실제 node_modules 패키지 조회를 수행하니까요. (npm / yarn / pnpm) 이건 특히 monorepo 패키지를 npm에 배포할 예정이라면 중요해요. 사용자들이 설치하면 패키지들이 node_modules 패키지 조회로 서로를 참조하게 되고, workspaces를 쓰면 로컬 개발 중에 그 동작을 테스트할 수 있거든요.
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패키지 디렉터리에서 타입 해석을 시도해요.- 결과를 찾으면 그것을 반환하고 검색을 멈춰요.
- 이번에는
@types디렉터리에서 검색하지 않고, JavaScript 파일을 결과로 허용하면서 모든node_modules디렉터리를 대상으로 앞선 검색을 반복해요.
모든 moduleResolution 모드(classic 제외)가 이 패턴을 따르지만, 일단 패키지 디렉터리를 찾은 뒤 그 안에서 해석하는 방식의 세부 사항은 다르며, 다음 절들에서 설명해요.
package.json "exports"
moduleResolution이 node16, nodenext, 또는 bundler로 설정되고 resolvePackageJsonExports가 비활성화되지 않았을 때, TypeScript는 bare 지정자 node_modules 패키지 조회로 촉발된 패키지 디렉터리에서 해석할 때 Node.js의 package.json "exports" 스펙을 따라요.
모듈 지정자를 "exports"를 통해 파일 경로로 해석하는 TypeScript의 구현은 Node.js를 정확히 따라요. 그러나 파일 경로가 해석되면 TypeScript는 타입을 우선해 찾기 위해 여전히 여러 파일 확장자를 시도해요.
조건부 "exports"를 해석할 때 TypeScript는 항상 "types"와 "default" 조건이 있으면 그것을 일치시켜요. 또한 TypeScript는 "types@{selector}" 형태의 버전 지정 types 조건(여기서 {selector}는 "typesVersions" 호환 버전 선택자)을 "typesVersions"에 구현된 것과 같은 버전 일치 규칙에 따라 일치시켜요. 다른 비설정 가능 조건은 moduleResolution 모드에 의존하며 다음 절들에서 지정돼요. 추가 조건은 customConditions 컴파일러 옵션으로 일치하도록 설정할 수 있어요.
"exports"의 존재는 "exports"에 명시적으로 나열되거나 패턴으로 일치되지 않은 모든 하위 경로가 해석되는 것을 막는다는 점에 주의하세요.
예시: 하위 경로, 조건, 확장자 치환
시나리오: 아래 package.json을 가진 패키지 디렉터리에서 "pkg/subpath"를 조건 ["types", "node", "require"](moduleResolution 설정과 모듈 해석 요청을 촉발한 문맥으로 결정)로 요청해요.
{
"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" 조건
시나리오: 아래 package.json을 가진 패키지 디렉터리에서 "pkg/subpath"를 조건 ["types", "node", "import"]으로 요청해요.
{
"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를 쓰면서 아래 package.json을 가진 패키지 디렉터리에서 "pkg/subpath"를 조건 ["types", "node", "import"]으로 요청해요.
{
"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를 반환해요.
예시: 하위 경로 패턴
시나리오: 아래 package.json을 가진 패키지 디렉터리에서 "pkg/wildcard.js"를 조건 ["types", "node", "import"]으로 요청해요.
{
"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"가 읽히는 상황에서는 이 필드가 읽히지 않아요.
예시: 모든 요청을 하위 디렉터리로 방향 전환
시나리오: node_modules/pkg/package.json이 아래와 같을 때 모듈이 TypeScript 5.2로 "pkg"를 import해요.
{
"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를 반환해요.
예시: 특정 파일에 대한 요청 방향 전환
시나리오: node_modules/pkg/package.json이 아래와 같을 때 모듈이 TypeScript 3.9로 "pkg"를 import해요.
{
"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-relative file paths)
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, `.js` 확장자 필요
import "pkg/dist/foo.js"; // ✅
import foo = require("pkg/dist/foo"); // ✅ require, 확장자 불필요
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는 "imports"와 self reference에 대한 Node.js의 해석 알고리즘을 파일 경로가 해석될 때까지 정확히 따르고, 그 시점에 해석 중인 "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 소스 파일을 계속 사용할 수 있어요.
예시: 조건이 붙은 로컬 프로젝트
시나리오: tsconfig.json과 package.json을 가진 프로젝트 디렉터리에서 "/src/main.mts"가 "#utils"를 조건 ["types", "node", "import"]으로 import해요.
// 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 디렉터리 안에 있나요? 네.
./dist/utils.d.mts에서outDir접두사를rootDir로 바꿔요../src/utils.d.mts- 출력 확장자
.d.mts를 대응하는 입력 확장자.mts로 바꿔요../src/utils.mts - 파일이 존재하면
"./src/utils.mts"경로를 반환해요. - 그렇지 않으면 파일이 존재할 때
"./dist/utils.d.mts"경로를 반환해요.
- package.json이
예시: 하위 경로 패턴을 가진 node_modules 의존성
시나리오: package.json을 가진 /node_modules/pkg/main.mts가 "#internal/utils"를 조건 ["types", "node", "import"]으로 import해요.
// /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 문을 흔히 보게 된다는 뜻이에요. 이것이 헷갈린다면, require 호출로 내보내질 import 문의 사용을 금지하는 verbatimModuleSyntax 컴파일러 옵션을 켤 수 있어요.
동적 import() 호출은 Node.js 동작에 따라 항상 import 알고리즘으로 해석된다는 점에 주의하세요. 하지만 import() 타입은 import 파일의 형식에 따라 해석돼요(기존 CommonJS 형식 타입 선언과의 하위 호환을 위해).
// @Filename: module.mts
import x from "./mod.js"; // 파일 형식 때문에 `import` 알고리즘 (그대로 내보내짐)
import("./mod.js"); // 구문 때문에 `import` 알고리즘 (그대로 내보내짐)
type Mod = typeof import("./mod.js"); // 파일 형식 때문에 `import` 알고리즘
import mod = require("./mod"); // 구문 때문에 `require` 알고리즘 (`require`로 내보내짐)
// @Filename: commonjs.cts
import x from "./mod"; // 파일 형식 때문에 `require` 알고리즘 (`require`로 내보내짐)
import("./mod.js"); // 구문 때문에 `import` 알고리즘 (그대로 내보내짐)
type Mod = typeof import("./mod"); // 파일 형식 때문에 `require` 알고리즘
import mod = require("./mod"); // 구문 때문에 `require` 알고리즘 (`require`로 내보내짐)
내포·강제되는 옵션
--moduleResolution node16과nodenext은--module node16,node18,node20, 또는nodenext과 짝을 이뤄야 해요.
지원 기능
기능은 우선 순위 순서로 나열돼요.
| 기능 | import |
require |
|---|---|---|
paths |
✅ | ✅ |
baseUrl |
✅ | ✅ |
node_modules 패키지 조회 |
✅ | ✅ |
package.json "exports" |
✅ matches types, node, import |
✅ matches types, node, require |
package.json "imports"와 self-name import |
✅ matches types, node, import |
✅ matches types, node, require |
package.json "typesVersions" |
✅ | ✅ |
| 패키지 상대 경로 | ✅ when exports not present |
✅ when exports not present |
| 전체 상대 경로 | ✅ | ✅ |
| 확장자 없는 상대 경로 | ❌ | ✅ |
| 디렉터리 모듈 | ❌ | ✅ |
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"; // "import" 조건으로 해석됨
import pkg2 = require("pkg"); // "require" 조건으로 해석됨
내포·강제되는 옵션
--moduleResolution bundler는--module esnext나--module preserve와 짝을 이뤄야 해요.--moduleResolution bundler는--allowSyntheticDefaultImports를 내포해요.
지원 기능
paths✅baseUrl✅node_modules패키지 조회 ✅- package.json
"exports"✅ matchestypes,import/requiredepending on syntax - package.json
"imports"와 self-name import ✅ matchestypes,import/requiredepending on syntax - package.json
"typesVersions"✅ - 패키지 상대 경로 ✅ when
exportsnot present - 전체 상대 경로 ✅
- 확장자 없는 상대 경로 ✅
- 디렉터리 모듈 ✅
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은 쓰지 마세요.
더 알아보기
- TypeScript 모듈에 대한 이론적 배경: Module theory
- ESM/CJS 상호 운용 부록: ESM/CJS Interoperability
- Node.js 패키지 진입점 스펙: package.json "exports"
- Module resolution 관련 이슈: #54018