《TypeScript编程实战》2.2 环境变量与配置的类型化

本节把环境变量与配置纳入类型系统。先讲清 process.env 的真实类型为什么是 string 或 undefined,再依次给出三档方案:手写声明合并、Zod 启动时校验加类型推导、以及多环境文件的分层加载。随后讨论密钥不进仓库、构建期变量与运行期变量的区别,并列出配置项缺失、布尔值被解析成字符串等常见坑与真实报错。读完你能写出一份启动即校验、类型自动推导的配置模块。

本节目标:让「配置缺了一项」从上线后的运行时崩溃,提前成启动时的一条清晰报错。读完后你能说清 process.env 的类型真相,能用 Zod 写出一份自动推导类型的配置模块,并知道多环境文件该按什么顺序加载。

2.2 环境变量与配置的类型化

上一节我们把模块来源理清了。但项目里还有一类值不属于任何模块:数据库地址、端口号、第三方密钥、功能开关。它们随部署环境变化,写法上却极其原始——从 process.env 里读一个字符串,然后祈祷它存在。

这类值恰恰是事故高发区。本节要做的,是让它们也进入类型系统,并且在进程启动的第一秒就完成校验。

2.2.1 process.env 的类型真相

先看清楚起点。process.env 的类型是这样定义的(来自 @types/node):

interface ProcessEnv {
  [key: string]: string | undefined;
}

两个事实值得停下来看:

  1. 索引签名是 string | undefined。 也就是说 process.env.PORT 的类型不是 string,而是 string | undefined。你把它直接传给需要一个 string 的函数,在 strict 模式下会报错——这是 TypeScript 在帮你,因为环境变量确实可能不存在。
  2. 所有值都是 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();

这一份代码同时解决了四个问题:

  1. 缺项即崩溃,且崩溃得很清楚。 safeParse 不通过时打印出具体哪个字段错了,进程退出码为 1。部署平台会立刻标记这次发布失败,而不是让服务带着 NaN 端口跑起来。
  2. 类型自动推导。 z.infer 从 schema 算出 AppConfig,config.PORT 的类型是 number——Zod 的 z.coerce.number() 会在校验时执行 Number(value)。
  3. 转换集中在一处。 PORT 从 "3000" 变成 3000,ENABLE_CACHE 从 "true" 变成 true,全项目拿到的都是转换后的值。
  4. 默认值写在 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes