痛點先說

剛起手的 Express 專案,你可能在 database.ts 寫了:

import { Sequelize } from 'sequelize';
 
const sequelize = new Sequelize(
  process.env.DB_NAME!,
  process.env.DB_USER!,
  process.env.DB_PASSWORD!,
  { host: process.env.DB_HOST, dialect: 'mysql' }
);

然後在 app.ts 又寫了:

app.use(cors({ origin: process.env.CORS_ORIGIN }));

然後在 logger.ts 又寫了:

const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info' });

三個月後你要把 LOG_LEVEL 改名成 APP_LOG_LEVEL,你得 grep 整個 codebase 找所有直接讀 env 的地方——然後你才發現有 12 個地方、分散在 8 個檔案。一半有預設值、一半沒有。有些有型別轉換、有些沒有。

這就是沒有 config 層的代價:env 變數分散在整個 codebase 裡,沒有任何集中管理,也沒有型別安全。


設計核心:兩層分離

Proto 的 configs 層解法是兩層結構:

src/app/configs/
├── process.ts       ← 第一層:唯一讀 process.env 的地方
├── app.ts           ← 第二層:各自負責一個關切點
├── cors.ts          ←
├── database.ts      ←
├── logging.ts       ←
├── maintenance.ts   ←
├── rateLimit.ts     ←
├── redis.ts         ←
├── security.ts      ←
├── session.ts       ←
├── contentType.ts   ←
├── migrations.ts    ←
├── requestSize.ts   ←
├── validator.ts     ←
└── index.ts         ← 集中 re-export

process.ts 是唯一接觸 process.env 的地方。

所有其他 config 檔都只從 process.ts import,不直接讀 env。


第一層:process.ts

process.ts 做兩件事:

  1. 呼叫 dotenv.config() 載入 .env
  2. 把所有 env 變數讀進來、設預設值、做型別轉換,整個包成一個型別安全的 envConfig 物件
import dotenv from 'dotenv';
dotenv.config(); // ← 必須在這裡,import 之前
 
const envConfig: ProcessConfig = {
  port: process.env.PORT || 3000,
  env: process.env.NODE_ENV || 'development',
 
  database: {
    default: {
      host: process.env.DB_HOST || 'localhost',
      user: process.env.DB_USER || 'root',
      password: process.env.DB_PASSWORD || '',
      name: process.env.DB_NAME || 'express_app',
      port: parseInt(process.env.DB_PORT || '', 10) || 3306,
      dialect: (process.env.DB_CONNECTION as 'mysql' | 'postgres' | 'sqlite') || 'mysql',
    },
  },
 
  redis: {
    isEnabled: process.env.IS_ENABLE_REDIS === 'true',
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '', 10) || 6379,
    password: process.env.REDIS_PASSWORD || '',
  },
 
  // ...還有 smtp / aws / session / jwt / logging 等等
};
 
export default envConfig;

這層的責任邊界很明確:只負責「把 env 讀進來、給預設值、做型別轉換」。不做任何 library 初始化、不做任何商業邏輯。


第二層:各 config 檔

每個 config 檔從 process.ts import,然後把資料重塑成各自對應的 library 型別

cors.ts 為例:

import envConfig from './process';
import { CorsOptions } from 'cors'; // ← cors library 的型別
 
const { corsOrigin } = envConfig;
 
const corsConfig: CorsOptions = {   // ← 直接用 library type
  origin: corsOrigin,
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
  preflightContinue: false,
  optionsSuccessStatus: 204,
};
 
export default corsConfig;

這個 config 檔輸出的型別是 CorsOptions——直接餵給 app.use(cors(corsConfig)) 不需要任何轉換。TypeScript 在這裡自動幫你確認型別對不對。

session.ts 是同樣邏輯:

import { SessionOptions } from 'express-session'; // ← session library 的型別
 
const sessionConfig: SessionOptions = {
  secret: session.secret || 'secret',
  resave: false,
  saveUninitialized: true,
  cookie: { secure: env === 'production' }, // ← env 在這裡被用到了
};

security.ts 輸出 HelmetOptionsrateLimit.ts 輸出 Partial<Options>logging.ts 輸出自訂的 LoggingConfig。每個都是 typed。


為什麼要這樣拆,而不是每個 config 各自讀 env?

替代方案cors.ts 直接讀 process.env.CORS_ORIGINdatabase.ts 直接讀 process.env.DB_*,各管各的。

這看起來也沒什麼問題,直到你遇到這幾個場景:

場景一:env 變數改名 如果分散讀,你要改 12 個地方。用 process.ts 集中管,你只改一個地方,其他全部跟著走。

場景二:審計目前用了哪些 env 變數 集中在 process.ts,打開一個檔案就知道這個專案需要哪些環境變數。分散讀,你得 grep 全部 codebase,還可能漏掉。

場景三:同一個 env 在多個 config 被用 process.env.NODE_ENVsession.ts(決定 cookie.secure)、database.ts(決定 logging 是否開)、logging.ts(決定 log level)都需要。集中讀一次、型別轉換一次,比三個地方各自 process.env.NODE_ENV 乾淨。


dotenv 載入時機:一個常見的坑

dotenv.config() 必須在 process.ts 的最頂層執行,在任何 import 之前。

import dotenv from 'dotenv';
dotenv.config(); // ← 這一行要在最前面
 
const envConfig = { ... }; // ← 這時才讀 process.env.*

為什麼?因為 Node.js 的 module system 是在 import 時就執行模組程式碼。如果你在 app.ts 才呼叫 dotenv.config(),但 app.ts 本身 import 了 database.ts,而 database.ts 又 import 了 process.ts——process.tsdotenv.config() 執行之前就已經跑完了,所有 process.env.* 都是 undefined。

解法就是讓 process.ts 自己負責呼叫 dotenv.config(),放在模組最頂層。 這樣不管誰先 import process.ts,dotenv 都已經就緒了。


型別安全的實作策略

Proto 用的是 TypeScript interface + manual type assertion,沒有用 zod。

// types/config.ts
export interface ProcessConfig {
  port: string | number;
  env: string;
  database: Record<string, DatabaseConfig>;
  redis: RedisConfig;
  // ...
}
 
// process.ts
const envConfig: ProcessConfig = { ... };

這個做法夠用,但有一個缺點:port: process.env.PORT || 3000 其實是 string | number,沒有在 runtime 真正驗證 PORT 的格式。

如果你想要更嚴格的 runtime 驗證:

import { z } from 'zod';
 
const envSchema = z.object({
  PORT: z.string().regex(/^\d+$/).transform(Number),
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DB_HOST: z.string().min(1),
  // ...
});
 
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
  console.error('❌ Invalid environment variables:', parsed.error.format());
  process.exit(1); // ← 啟動時就發現,不是跑一半才爆
}
 
export default parsed.data;

用 zod 的好處是啟動就擋掉,不會等到真正用到那個 env 才噴錯。Proto 選擇不用 zod 是為了減少依賴,但如果你的團隊對 runtime 驗證有要求,加 zod 是正確的方向。


index.ts 的集中 re-export

最後,src/app/configs/index.ts 把所有 config 集中 re-export:

export { default as appConfig } from './app';
export { default as corsConfig } from './cors';
export { default as databaseConfig, sequelize, getSequelizeConnection } from './database';
export { default as loggingConfig } from './logging';
export { default as maintenanceConfig } from './maintenance';
export { default as rateLimitConfig } from './rateLimit';
export { default as redisConfig } from './redis';
export { default as securityConfig } from './security';
export { default as sessionConfig } from './session';

外部使用:

import { corsConfig, securityConfig, sessionConfig } from '@/app/configs';
// 不需要知道各自在哪個檔案

這層的位置與後續

Config 層是整個系統的最底層依賴

bootstrap/ → 用 configs 做 DB init、Redis init、middleware 掛載
middleware/ → 用 corsConfig / securityConfig / rateLimitConfig
routes/    → 間接用(透過 controller / service)

bootstrap/ 一定要在 configs 之後 init,因為它需要型別安全的設定才能正確初始化各依賴。這個啟動順序會在下一篇 Bootstrap 模式 詳細展開。


本系列文章

  • [[backend/framework/express/init|[express][01] Express + TypeScript 從零開始]]
  • [[backend/framework/express/eslint-typings|[express][02] ESLint + Typings 設定]]
  • [[backend/framework/express/jest-setup|[express][02-2] Jest 基礎設定]]
  • [express][M1] Config 層設計(本篇)
  • [[backend/framework/express/src-structure|[express][M1-2] 目錄結構設計]]
  • [[backend/framework/express/bootstrap-pattern|[express][M2] Bootstrap 模式與啟動序列]]