本节目标:让「配置缺了一项」从上线后的运行时崩溃,提前成启动时的一条清晰报错。读完后你能说清
process.env的类型真相,能用 Zod 写出一份自动推导类型的配置模块,并知道多环境文件该按什么顺序加载。
2.2 环境变量与配置的类型化
上一节我们把模块来源理清了。但项目里还有一类值不属于任何模块:数据库地址、端口号、第三方密钥、功能开关。它们随部署环境变化,写法上却极其原始——从 process.env 里读一个字符串,然后祈祷它存在。
这类值恰恰是事故高发区。本节要做的,是让它们也进入类型系统,并且在进程启动的第一秒就完成校验。
2.2.1 process.env 的类型真相
先看清楚起点。process.env 的类型是这样定义的(来自 @types/node):
interface ProcessEnv {
[key: string]: string | undefined;
}
两个事实值得停下来看:
- 索引签名是
string | undefined。 也就是说process.env.PORT的类型不是string,而是string | undefined。你把它直接传给需要一个string的函数,在strict模式下会报错——这是 TypeScript 在帮你,因为环境变量确实可能不存在。 - 所有值都是
string。 环境变量没有数字、没有布尔、没有数组。process.env.PORT拿到的是"3000"而不是3000;process.env.DEBUG拿到的是"false",而"false"是真值。
第二个事实是本节所有坑的总源头:
// 这段代码看起来天经地义,实际是 bug
if (process.env.ENABLE_CACHE) {
enableCache();
}
// ENABLE_CACHE=false 时,这个分支照样会执行
// 因为非空字符串 "false" 是 truthy
正确写法必须显式比较:
if (process.env.ENABLE_CACHE === "true") {
enableCache();
}
但更根本的解法是只在一个地方做转换,之后全项目都用转换后的类型化对象。这就是下面三档方案要解决的问题。
2.2.2 十二要素与配置分层
在动手写代码前,先定一个原则。十二要素应用(12-Factor App)里关于配置的核心主张是:配置属于环境,不属于代码。同一个构建产物,通过不同的环境变量就能跑在开发、预发、生产三套环境上。
按这个原则,配置天然分三层:
| 层次 | 例子 | 是否随环境变化 | 是否可进仓库 |
|---|---|---|---|
| 常量 | 应用名、API 版本前缀 | 否 | 是,直接写在代码里 |
| 环境相关非密 | 端口、日志级别、外部 API 地址 | 是 | 是,进 .env.example |
| 密钥 | 数据库密码、JWT 签名密钥 | 是 | 绝不进仓库 |
只有第二、三层才需要走环境变量。第一层写在代码里就好——把应用名也做成环境变量,只会让配置面变大而没有任何收益。
分层带来的一个直接推论是:配置模块必须是一个「边界」。它是整个应用里唯一允许出现 process.env 的地方,其他所有模块都从配置模块 import 类型化的值。这样密钥泄漏、类型错误、缺项崩溃都只可能在边界上发生,而边界只有一个。
Zod 示例沿用 zod@3.25.76(pnpm add --save-exact zod@3.25.76),并在 TS 严格模式下检查;升级 Zod 4 时需重新核对 schema 输入类型与错误格式。
2.2.3 第一档:手写声明合并
最轻量的做法是用 TypeScript 的声明合并,把 ProcessEnv 的索引签名细化:
// src/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
PORT: string;
DATABASE_URL: string;
JWT_SECRET: string;
LOG_LEVEL?: "debug" | "info" | "warn" | "error";
}
}
declare global 也可以,但在 .d.ts 里直接 declare namespace NodeJS 更简洁。合并之后:
const port: string = process.env.PORT;
// 类型上不再有 undefined,但这是「你承诺的」,不是「运行时保证的」
这里必须诚实地指出:声明合并只是把 undefined 从类型里抹掉了,它不产生任何运行时保证。如果 .env 里忘了写 PORT,process.env.PORT 在运行时就是 undefined,但类型上它是 string——错误被推迟到了更难排查的地方(比如 Number(undefined) 得到 NaN,服务监听在 NaN 端口上)。
所以这一档只适合「环境变量由部署平台强制注入」的场景(Kubernetes 的 required env、Vercel 的环境变量面板),那里缺项会在部署阶段被拦下。本地开发与自建部署都不该只靠它。
2.2.4 第二档:Zod 启动时校验(推荐)
真正的解法是把校验放到运行时,同时让类型从校验规则里推导出来。用 Zod 只需一份 schema:
// src/config.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT_SECRET 至少 32 位"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_CACHE: z.coerce.boolean().default(false),
});
export type AppConfig = z.infer<typeof envSchema>;
function loadConfig(): AppConfig {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("配置校验失败:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
return parsed.data;
}
export const config = loadConfig();
这一份代码同时解决了四个问题:
- 缺项即崩溃,且崩溃得很清楚。
safeParse不通过时打印出具体哪个字段错了,进程退出码为 1。部署平台会立刻标记这次发布失败,而不是让服务带着NaN端口跑起来。 - 类型自动推导。
z.infer从 schema 算出AppConfig,config.PORT的类型是number——Zod 的z.coerce.number()会在校验时执行Number(value)。 - 转换集中在一处。
PORT从"3000"变成3000,ENABLE_CACHE从"true"变成true,全项目拿到的都是转换后的值。 - 默认值写在 schema 里。
default()让本地开发不必配齐所有变量,同时把默认值这件事显式化了。
z.infer 是这套方案的关键——它保证了校验规则与类型定义永远同步。手写 interface AppConfig 再手写校验逻辑时,两者会随迭代漂移;用 z.infer 则不可能漂移,因为类型是算出来的,不是写出来的。
2.2.5 几个必须知道的 Zod 细节
z.coerce 的语义
z.coerce.number() 内部调用 Number(),因此 "" 会被转成 0,"abc" 会被转成 NaN 并被 int() 拒绝。这比手写 parseInt 更严格——parseInt("3000abc") 会安静地返回 3000,而 z.coerce.number() 会报错。校验场景下宁可严一点。
z.coerce.boolean() 的行为值得警惕:它遵循 JavaScript 的真值规则,因此 "false" 会被转成 true(非空字符串为真值)。想要严格的布尔解析,应该显式写:
const configExcerpt = {
ENABLE_CACHE: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
};
z.infer 与 z.input 的区别
z.infer(等价于 z.output)是校验后的类型,z.input 是校验前的类型。上面 ENABLE_CACHE 用了 transform 时两者不同:z.input 是 "true" | "false",z.infer 是 boolean。业务代码用 z.infer,只有写测试或做二次校验时才需要 z.input。
校验失败的输出长什么样
配置校验失败:
{
DATABASE_URL: [ 'Invalid url' ],
JWT_SECRET: [ 'JWT_SECRET 至少 32 位' ]
}
注意 fieldErrors 会把所有错误一次性列出,而不是遇到第一个就停。这对「新同事第一次跑项目」的场景特别友好——一次补全所有变量,而不是修一个报一个。
2.2.6 .env 文件的加载
Zod 校验的是 process.env,但本地开发时这些值从哪来?答案是 .env 文件。有两条路:
路线一:Node 原生 --env-file
Node 20.6+ 内置了 .env 加载,不需要任何依赖:
node --env-file=.env dist/index.js
在 package.json 里固化成脚本:
{
"scripts": {
"start": "node --env-file=.env dist/index.js",
"dev": "tsx watch --env-file=.env src/index.ts"
}
}
路线二:dotenv
需要更复杂的行为(多文件叠加、变量展开、按环境选文件)时用 dotenv:
npm i dotenv
import "dotenv/config";
这一行会在模块加载时读取 .env 并写入 process.env。注意它必须在配置模块之前执行——import "dotenv/config" 的副作用顺序依赖 ESM 的模块求值顺序,最稳妥的做法是把它作为入口文件的第一行 import,或者直接在配置模块顶部 import 它。
多环境文件的加载顺序
真实项目往往有多个文件,约定俗成的优先级是「越具体越优先」:
.env # 所有环境的默认值
.env.local # 本机私有覆盖,不进仓库
.env.development # 开发环境默认值
.env.development.local # 开发环境本机覆盖
用 dotenv 显式叠加:
import { config as loadEnv } from "dotenv";
const nodeEnv = process.env.NODE_ENV ?? "development";
loadEnv({ path: ".env" });
loadEnv({ path: `.env.${nodeEnv}`, override: true });
loadEnv({ path: `.env.${nodeEnv}.local`, override: true });
loadEnv({ path: ".env.local", override: true });
override: true 表示后加载的覆盖先加载的。已存在的真实环境变量优先级永远最高——这是十二要素的要求,也是本地调试时能用 PORT=4000 npm run dev 临时改端口的前提。
2.2.7 密钥不进仓库
.env 里装的是密钥,它绝对不能进 git。第一件事是在 .gitignore 里排除它们:
.env
.env.local
.env.*.local
第二件事是在仓库里放一份 .env.example,只留键名与示例值:
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:password@localhost:5432/app
JWT_SECRET=replace-me-with-at-least-32-characters
LOG_LEVEL=info
ENABLE_CACHE=false
.env.example 的价值在于它是可执行的文档:新同事 clone 之后 cp .env.example .env 就能跑起来。更好的做法是让 CI 校验二者键名一致——用同一份 Zod schema 的 keyof 去比对,缺键就报错。这正是「配置即 schema」带来的额外收益。
密钥管理本身是个大话题(Vault、KMS、云平台的 Secret Manager),本书不展开。这里只需记住一条底线:仓库里出现真实密钥的那一刻,这个密钥就已经泄漏了,因为 git 历史会永久保留它。发现误提交时,正确动作不是删文件再提交,而是立刻轮换该密钥。想了解服务端的密钥实践可以延伸阅读 密钥管理实践 。
2.2.8 构建期变量与运行期变量
前端项目还有一个额外维度:变量在构建时被内联进产物,而不是运行时读取。Vite 用 import.meta.env 表达这一点:
// 只有 VITE_ 前缀的变量会被注入
const apiBase = import.meta.env.VITE_API_BASE_URL;
对应的类型声明:
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_ENABLE_MOCK?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
必须清楚地区分两类变量:
| 类别 | 读取方式 | 何时确定 | 能否放密钥 |
|---|---|---|---|
| 构建期 | import.meta.env.VITE_* | 构建时被替换成字面量 | 不能,产物里明文可见 |
| 运行期 | process.env.*(服务端) | 进程启动时 | 可以 |
构建期变量会被原样打进 JavaScript 文件,任何人打开产物都能看到。所以 VITE_DATABASE_PASSWORD 这种写法等价于把密码贴在网页上。前端要用密钥,只能通过服务端接口代理。
另外注意 VITE_ENABLE_MOCK 这类开关在类型上是 string | undefined,仍需显式比较 === "true",坑与 2.2.1 完全一致。
2.2.9 常见坑与真实报错
坑一:process.env.PORT 直接参与运算
const port = process.env.PORT + 1;
// "3000" + 1 → "30001",字符串拼接而不是加法
严格模式下 process.env.PORT 是 string | undefined,+ 会被允许(字符串拼接合法),于是编译器不会报错。这类 bug 极难发现,因为 "30001" 传给 listen 也「能跑」。解法就是 2.2.4 的 z.coerce.number()。
坑二:error TS2322: Type 'string | undefined' is not assignable to type 'string'
把环境变量直接赋给需要 string 的位置。要么在配置模块统一转换,要么用 ?? 给默认值。不要用 ! 非空断言——那只是把错误从编译期挪到了运行期,正是本节要消灭的东西。
坑三:配置模块在测试里被执行
config.ts 顶层的 loadConfig() 会在 import 时执行,测试环境里可能没有完整的环境变量,导致测试文件一 import 就退出进程。解法有两种:把校验包成函数、测试里用 vi.stubEnv 预设变量;或者用 z.object 的 .partial() 造一份测试专用 schema。第 4 章的测试会用到这个技巧。
坑四:import "dotenv/config" 放在配置模块之后
表现为「.env 里的值读不到,全是 undefined」。ESM 的 import 提升会让所有 import 先于其他语句求值,顺序判断容易出错。稳妥做法是在配置模块内部第一行加载 .env,而不是依赖入口文件的顺序。
坑五:改了 .env 但服务行为没变
tsx watch 默认监听源码变化,不监听 .env。改完环境变量要手动重启。这不是 bug,但要写进团队文档,否则会浪费很多时间。
小结
本节把配置从「散落各处的 process.env 读取」收拢成一个受控边界。我们先是确认了两个底层事实:process.env 的值类型是 string | undefined,且运行时永远只有字符串——这解释了布尔开关与数字端口为何总出问题。随后给出三档方案:声明合并最轻但无运行时保证;Zod 校验加重推导是推荐方案,它让类型与校验规则不可能漂移,并让进程在配置缺失时立刻退出;多环境文件按「越具体越优先」叠加,真实环境变量优先级最高。最后区分了构建期变量与运行期变量,明确了前端产物里不能放密钥这条底线。
到这里项目的静态结构(目录、模块、配置)已经全部类型化了。但当类型错误消失之后,剩下的 bug 只能靠运行时观察——而运行时跑的是编译产物,行号与源码对不上。下一节 2.3 调试与 source map
会解决这个问题:让断点准确落在你写的 .ts 那一行。如果你还想深入运行时校验与 schema 的配合,可以延伸阅读 TypeScript 运行时校验与类型安全
与 TypeScript 与 Zod 校验实践
。
阅读导航:上一节:2.1 路径别名与 monorepo 结构 · 下一节:2.3 调试与 source map 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。