老胡茶室
老胡茶室

在 NodeJS 工程中如何实现类型安全的环境变量管理

冯宇

前言

说到环境变量,在业务开发中很常见,为了避免硬编码,我们会把配置放入环境变量中,在运行或者构建的过程中读取。它使用很方便,也很灵活,但是可能潜藏了一些隐患,比如:

  • 部署后 API 404,一查发现是 API_URl(小写 L)写错了;
  • .env 忘了带,服务一启动就“看起来很正常”,结果业务全是空值;
  • PORT="abc" 没报错,但服务死活起不来

这些都不是代码不够聪明,而是 env 没有类型安全

现实: 环境变量从来不是“类型安全”

无论是 process.env 还是 import.meta.env,本质上都是一堆字符串。它们不会校验、不知道你期望的类型,也不会在构建阶段提醒你“你写错了”。

所以我们需要的不是“更炫酷的写法”,而是:

让错误更早暴露,让类型更清晰,让 server/client 边界更明确。

process.env vs import.meta.env:

  • process.env:运行时读取、进程级别,适合服务端
  • import.meta.env:构建期注入,更常见于前端

以 Vite 为例,只有带 VITE_ 前缀的变量才能进客户端,且全部都是字符串。这意味着:你看到的是“类型”,实际得到的是“字符串形态的意志”。

为了让环境变量能够类型安全,我们需要辅助一些工具实现。

手搓方案:Zod + dotenv

这是最轻量的一种解决方案:

import { z } from "zod";
// 仅需要 .env 支持的时候才需要
import "dotenv/config";

const envSchema = z.object({
  API_URL: z.string().url(),
  PORT: z.coerce.number().default(4321),
});

export const env = envSchema.parse(process.env);

它的好处很直白:

  • 依赖少,核心依赖只有 Zod,并且 Zod 本身也足够轻量
  • 规则清晰,只要熟练掌握 zod,可以在 schema 里定义任意复杂的规则,可以玩出花来

但也有缺点:

  • server / client 边界需要你自己约定
  • 多项目会出现样板重复

工具化方案: T3 Env

T3 Env (https://env.t3.gg/) 基于 Zod,把“类型 + 访问边界 + 运行时校验”打包给你,尤其适合想减少重复劳动的项目。而且官方还提供 Next.js 和 Nuxt.js 的集成方案,在这些框架中使用也很方便。

注意,它并不是什么框架专属,不与任何开发框架绑定。@t3-oss/env-core 在任何 Nodejs 工程都能随意使用:

import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
  },
  clientPrefix: "PUBLIC_",
  client: {
    PUBLIC_APP_NAME: z.string(),
  },
  runtimeEnv: process.env,
  emptyStringAsUndefined: true,
});

这里有两个关键词值得高亮:

  • clientPrefix:客户端变量必须有前缀,和 Vite 的 VITE_ 类似,避免不小心把秘密变量暴露给客户端
  • emptyStringAsUndefined:避免 PORT= 这种“空字符串假装有值”的骚操作

T3 Env 的 Next.js 集成

如果你在 Next.js,用 @t3-oss/env-nextjs 会更顺滑,尤其是 server/client 的拆分体验:

import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
  },
  client: {
    NEXT_PUBLIC_APP_NAME: z.string(),
  },
  experimental__runtimeEnv: process.env,
});

它内部默认配置的 clientPrefix 就是 NEXT_PUBLIC_,和 Next.js 的行为对齐,并且在 Next.js 的运行时环境中自动读取 process.env

Astro:官方自带“类型安全”

Astro 直接提供一个官方虚拟模块:astro:env,用户管理环境变量。

配置在 astro.config.mjs 里,用 envField 定义类型、上下文、访问级别:

// astro.config.mjs
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  env: {
    schema: {
      API_URL: envField.string({ context: "client", access: "public" }),
      API_SECRET: envField.string({ context: "server", access: "secret" }),
    },
  },
});

使用时要从 astro:env/clientastro:env/server 分开引入:

import { API_URL } from "astro:env/client";
import { API_SECRET } from "astro:env/server";

const data = await fetch(`${API_URL}/users`, {
  headers: {
    Authorization: `Bearer ${API_SECRET}`,
  },
});

它本质上是基于 zod 做的简易封装,底层依旧使用 zod 校验。

astro:env 的几个实用小贴士:

  • access: public 会在构建期内联进 bundle,secret 留在运行时,前者相当于 import.meta.env 的行为,后者相当于 process.env 的行为
  • access 只对 server 变量有意义,client 一律 public,也无需声明
  • astro:env 只能在 Astro 运行时上下文使用(组件、路由、中间件)
  • 类型会在 astro dev / astro build 时生成,也可以使用 astro sync 在不运行项目的情况下生成类型定义

总结

本文我们讨论了环境变量的“类型安全”,以及如何实现。简单来说,期望项目依赖尽可能轻量,可以使用 Zod + dotenv 的手搓方案;如果想要更完善的功能和更少的重复劳动,可以使用 T3 Env;而在 Astro 项目中,官方自带的 astro:env 已经足够好用。

精品内容