1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // 命名导入
import { readFile, writeFile } from "node:fs/promises";
// 默认导入
import path from "node:path";
// 命名空间导入
import * as fs from "node:fs";
// 重命名
import { readFile as read } from "node:fs/promises";
// 副作用导入(只为执行)
import "./polyfills";
// 类型专用导入 🆕
import type { User } from "./types";
import { type User, createUser } from "./types"; // 混合写法
// 动态导入(返回 Promise)
const mod = await import("./lazy");
|
| 写法 | 编译产物 | 何时用 |
|---|
import { X } | 保留(若 X 是值) | 普通导入 |
import type { X } | 完全删除 | 只当类型用时 🔥 |
import { type X, y } | 只保留 y | 混用 |
import * as ns | 保留 | 需要整体引用 |
⚠️ import type 是纯粹的编译期构造,运行时不产生任何代码:
1
2
| import type { User } from "./types";
// 编译后:这一行完全消失
|
🔥 开启 verbatimModuleSyntax 后,类型必须显式用 import type,否则报错——这条规则能让输出与源码一一对应,也能让打包器(esbuild/SWC)正确处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 命名导出(推荐)
export const VERSION = "1.0";
export function createUser() {}
export class Service {}
// 类型导出
export interface User { id: number }
export type ID = string;
export type { User as UserShape }; // 重命名后导出
// 默认导出(每个模块最多一个)
export default class Client {}
// 再导出
export { readFile } from "node:fs/promises";
export * from "./utils";
export * as utils from "./utils"; // 命名空间再导出
// 类型再导出(isolatedModules 下必须写 type)
export type { User } from "./types";
|
命名导出 vs 默认导出:
| 命名导出 | 默认导出 |
|---|
| 数量 | 多个 | 1 个 |
| 导入时重命名 | 需要 as | 随意命名 |
| 重构友好度 | ✅ 改名能被追踪 | ❌ 改名容易漏 |
| Tree-shaking | ✅ 好 | ⚠️ 较差 |
| CJS 互操作 | ✅ 干净 | ⚠️ 需 esModuleInterop |
| 建议 | ✅ 优先用 🔥 | 框架约定需要时用 |
💭 在库的公开 API 上,优先命名导出。默认导出在 CJS/ESM 互操作、重命名重构、tree-shaking 三方面都更麻烦。
这两个选项约束的是「单文件转译」——esbuild、SWC、Babel 一次只看一个文件,无法知道某个导入是类型还是值。
1
2
3
| // 🛑 转译器无法判断 User 是不是类型,只能保守地保留这行导入
import { User } from "./types";
export type A = User;
|
开启 verbatimModuleSyntax 后:
1
2
3
4
5
6
7
8
| import { User } from "./types";
export type A = User;
// ❌ TS1484: 'User' is a type and must be imported using a type-only import
// when 'verbatimModuleSyntax' is enabled.
// ✅ 正确写法
import type { User } from "./types";
export type A = User;
|
再导出类型同理:
1
2
3
4
5
6
| export { User } from "./types";
// ❌ TS1205: Re-exporting a type when 'isolatedModules' is enabled requires
// using 'export type'.
// ✅
export type { User } from "./types";
|
| 选项 | 强制什么 | 推荐 |
|---|
isolatedModules | 禁止「无法单文件判断」的写法 | ✅ 打包器项目应开 |
verbatimModuleSyntax | 类型必须显式 import type | ✅ 现代项目应开 🔥 |
两个选项带来的额外约束:
| 写法 | isolatedModules 下 |
|---|
export { SomeType } | ❌ 必须 export type |
const enum | ❌ 不允许(见 16) |
| 只有类型导出/导入的文件 | ⚠️ 需要加 export {} 才算模块 |
1
2
3
4
| // 只有类型声明的文件,想成为「模块」必须显式标记
export {}; // 或 import type {} from "..."
interface Local { a: number } // 没有 export 时,这是全局声明!⚠️
|
⚠️ 上面这条是高频坑:一个 .ts 文件如果没有任何顶层 import/export,它是脚本(script),其中所有声明都是全局的。加上 export {} 才会变成模块。这就是为什么有些 .d.ts 会意外污染全局命名空间。
moduleResolution 决定「import "x" 去哪些地方找 x」。它必须和 module 搭配。
module | 推荐的 moduleResolution | 场景 |
|---|
nodenext | nodenext | Node.js ESM/CJS 双支持 🔥 |
node16 | node16 | 同上(旧名) |
esnext | bundler | Vite / Rollup / esbuild 🔥 |
preserve | bundler | 交给打包器,TS 只管检查 |
commonjs | node10 🗑️ | 已废弃,改用 nodenext |
amd / umd / systemjs | — | 🗑️ 6.0 全部废弃 |
6.0 废弃警告(实测):
| 配置 | 报错 |
|---|
moduleResolution: "node" 或 "node10" | TS5107 |
module: "amd" / "umd" / "systemjs" / "none" | TS5107 |
两种主流配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Node.js 项目
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}
// 打包器项目(Vite / Next.js / Rspack)
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}
|
关键差异:要不要写扩展名 🔥
| 解析策略 | import "./x" | import "./x.js" | import "./x.ts" |
|---|
bundler | ✅ 允许 | ✅ 允许 | ⚠️ 需 allowImportingTsExtensions |
nodenext(ESM) | ❌ 报错 | ✅ 必须 | ⚠️ 需 allowImportingTsExtensions |
node10 🗑️ | ✅ | ✅ | ❌ |
1
2
3
4
| // nodenext 下,ESM 必须写扩展名(且写 .js,不是 .ts)
import { foo } from "./foo.js"; // ✅ 即使源文件是 foo.ts
import { foo } from "./foo"; // ❌ TS2835: Relative import paths need explicit
// file extensions in ECMAScript imports.
|
⚠️ 「源文件是 .ts,但导入要写 .js」是最反直觉的一条。原因:import 语句会被原样保留到产物里,运行时它需要指向真实的 .js 文件。这不是 TS 的怪癖,而是 ESM 的规范要求。
导入 .ts 扩展名:需要显式开关——用于「Node 直接跑 TS」或打包器场景:
1
| { "compilerOptions": { "allowImportingTsExtensions": true, "noEmit": true } }
|
1
| import { v } from "./dep.ts"; // ✅ 开启后合法
|
相关选项 rewriteRelativeImportExtensions(TS 5.7+):编译时把 .ts 改写成 .js,从而既保留 .ts 源码写法,又产出正确的 JS:
1
2
3
4
5
6
| {
"compilerOptions": {
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
}
}
|
在 ESM 文件里导入 CJS 模块,历史上问题很多。esModuleInterop 提供了统一的处理方式——6.0 起它恒定开启,写 false 会报 TS5107。
1
2
3
| // CJS 模块(module.exports = fn)
import express from "express"; // ✅ 默认导入,靠 interop
import { Router } from "express"; // ✅ 命名导入(TS 会分析 CJS 导出的属性)
|
| 老写法 | 现代等价 | 说明 |
|---|
import x = require("m") | import x from "m" | 仅 CJS 输出下可用 |
export = X | export default X | 仅 CJS 输出下可用 |
import x = require() | — | 🚧 nodenext 下仍可用于 CJS 文件 |
export = 与 import = require() 在声明文件里仍会遇到(很多老库这样写):
1
2
3
4
5
6
7
| // CJS 风格声明
declare function foo(): void;
export = foo;
// 消费方
import foo = require("./cjs"); // ✅ 需在 CJS 模块(module: commonjs/node16 CJS)下
foo();
|
对应的 @types 包写法:
1
2
3
4
5
6
7
8
9
10
| // 一个典型的 CJS 库声明
declare namespace MyLib {
interface Options { debug?: boolean }
function init(o?: Options): void;
}
export = MyLib;
// 消费
import MyLib from "my-lib"; // ✅ 靠 esModuleInterop
import { init } from "my-lib"; // ✅ 命名导入也行
|
💡 遇到底层库导入报错时的排查顺序:① moduleResolution 是否是 bundler/nodenext → ② 该库的类型声明用的是 export = 还是 export default → ③ 是否需要 allowSyntheticDefaultImports(6.0 已恒开)→ ④ 最后才考虑手写 declare module。
paths 做路径别名(baseUrl 已在 6.0 废弃,前缀要写进每条映射里):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 🗑️ 旧写法(6.0 报 TS5101)
{
"compilerOptions": {
"baseUrl": "./src",
"paths": { "@/*": ["*"] }
}
}
// ✅ 6.0 写法:把前缀折叠进每条 paths
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"]
}
}
}
|
⚠️ paths 只影响类型检查,不影响运行时! TypeScript 不会重写导入路径。运行时还需要打包器或 imports 字段支持:
1
2
3
4
5
6
| // package.json:Node 原生的子路径导入
{
"imports": {
"#utils/*": "./src/utils/*.js"
}
}
|
或者用打包器的 resolve.alias。这里最容易出现「编辑器不报错但运行时找不到模块」。
types / typeRoots:
1
2
3
4
5
6
7
8
9
| {
"compilerOptions": {
// 6.0 默认 [],不再自动加载全部 @types!
"types": ["node", "vitest/globals"],
// 自定义类型根目录(很少需要改)
"typeRoots": ["./node_modules/@types", "./src/types"]
}
}
|
| 选项 | 作用 | 6.0 变化 |
|---|
types | 只加载列出的 @types 包 | 默认 [] ⚠️ 需显式声明 |
typeRoots | 从哪些目录找 @types | 默认 node_modules/@types |
paths | 路径别名 | baseUrl 已废弃 |
moduleSuffixes | 按后缀优先解析 | 少见,React Native 用 |
.d.ts 文件描述已存在的运行时代码,因此所有声明都带 declare。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // 变量
declare const VERSION: string;
declare let counter: number;
// 函数
declare function greet(name: string): string;
// 类
declare class Client {
constructor(url: string);
get(path: string): Promise<unknown>;
}
// 命名空间(对象)
declare namespace MathUtils {
function clamp(n: number, min: number, max: number): number;
const PI2: number;
}
// 模块
declare module "some-untyped-lib" {
export function doThing(): void;
}
// 全局扩充
declare global {
interface Window { myGlobal: string }
var myGlobalVar: number;
}
|
| 声明 | 描述的东西 |
|---|
declare const/let/var | 变量 |
declare function | 函数 |
declare class | 类 |
declare namespace | 命名空间对象 |
declare module "x" | 模块(含通配) |
declare global | 全局作用域 |
declare enum | 枚举 |
declare type/interface | 类型(不需要 declare) |
⚠️ type / interface 不需要也不能加 declare——它们在 .d.ts 里天然就是「声明」。
给非代码资源提供类型:
1
2
| /* styles.module.css */
.title { font-size: 14px; }
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // ✅ 在 globals.d.ts 里声明(必须是「不含顶层 import/export 的脚本文件」)
declare module "*.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
declare module "*.svg" {
const url: string;
export default url;
}
declare module "*.png" {
const url: string;
export default url;
}
declare module "*.json" {
const value: unknown;
export default value;
}
|
⚠️ 通配模块声明必须写在「脚本」文件里,否则报错:
1
2
3
4
5
6
| // globals2.ts —— 含顶层 import,所以这是「模块」不是「脚本」
import something from "./x";
declare module "*.foo" { const v: number; export default v; }
// ❌ TS2664: Invalid module name in augmentation, module '*.foo' cannot be found.
// 在模块文件里,declare module 被当作「模块扩充」,要求目标模块已存在。
|
| 文件类型 | 判定 | declare module "*.css" 的含义 |
|---|
| 脚本(无顶层 import/export) | ✅ 合法 | 环境声明,定义新模块 🔥 |
| 模块(有顶层 import/export) | ❌ TS2664 | 模块扩充,目标必须已存在 |
🔥 记住这个判定:.d.ts 里写通配模块时不要有任何顶层 import/export。需要引用其它类型就用 import("...").Type 这种内联形式:
1
2
3
4
5
6
7
8
9
| declare module "*.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
declare global {
// 需要类型时用内联 import
interface Window { store: import("./store").Store }
}
|
给已有的模块添加成员。这是给第三方库补类型的主要手段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 给 express 的 Request 加自定义属性
import "express";
declare module "express" {
interface Request {
user?: { id: string; roles: string[] };
}
}
// 给 vue 的组件实例加属性
import "vue";
declare module "vue" {
interface ComponentCustomProperties {
$format: (d: Date) => string;
}
}
|
扩充全局作用域:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| export {}; // 让本文件成为模块
declare global {
interface Window {
__APP_CONFIG__: { apiBase: string };
}
// 给 process.env 加类型
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
API_BASE: string;
}
}
}
|
| 想扩充 | 写法 |
|---|
| 第三方模块的接口 | declare module "包名" { interface X { ... } } |
全局 Window | declare global { interface Window { ... } } |
process.env | declare global { namespace NodeJS { interface ProcessEnv { ... } } } |
| 第三方库的命名空间 | declare module "包名" { ... } + import "包名" |
⚠️ 扩充是「加法」,不能覆盖。同名同类型的成员会声明合并,但类型冲突会报错。想改已有成员的类型,只能靠 Omit 重新定义接口,或使用 patch-package 之类的手段。
同名声明会自动合并。这是 interface 相对 type 的独有能力。
| 合并组合 | 结果 | 例子 |
|---|
interface + interface | 成员累加 | 库扩充的主要机制 🔥 |
namespace + namespace | 成员累加 | 分文件写同一命名空间 |
namespace + function | 函数带属性 | jQuery 风格 |
namespace + class | 类带静态成员 | — |
namespace + enum | 枚举带方法 | — |
function + function | ❌ 重载(不是合并) | 见 04 |
class + class | ❌ 报错 TS2300 | — |
type + type | ❌ 报错 TS2300 | — |
var + var | ❌ 报错 | 用 interface 代替 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // interface 合并
interface Box { a: number }
interface Box { b: string }
// 结果:{ a: number; b: string } ✅
// type 不能合并
type Box2 = { a: number };
type Box2 = { b: string }; // ❌ TS2300: Duplicate identifier 'Box2'.
// namespace + function(jQuery 风格)
declare function $(sel: string): void;
declare namespace $ {
const version: string;
}
$.("div"); // ✅ 函数可调用
$.version; // ✅ 也有属性
// namespace + class
class Album { constructor(public name: string) {} }
namespace Album {
export const DEFAULT = "untitled";
}
Album.DEFAULT; // ✅ 静态成员
|
⚠️ 合并顺序影响重载解析:后面的声明在重载中优先级更高。跨文件合并时顺序由编译顺序决定,不要依赖它。💭