io-ts如何组合codec实现环境变量的条件依赖校验
你需要的跨字段依赖校验,完全可以通过 codec 原生的组合能力实现,不需要编写臃肿的全量自定义 codec,也不需要把关联配置合并成单个对象环境变量。以下方案以你代码里使用的 io-ts 为例(从t.type的写法可以判断你用的是这个库),核心思路是用**判别联合(Discriminated Union)**匹配不同条件下的合法配置结构。
基础实现
首先把所有场景下都必填的公共配置抽离成独立 codec,避免重复编写:
import * as t from 'io-ts'; // 全局必填的公共配置,新增通用环境变量直接往这里加 const BaseConfig = t.type({ PORT_NUMBER: t.string, // 其他和业务开关无关的必填环境变量都可以放这里 });
之后通过 t.union + t.intersection 组合出两种合法配置分支,覆盖你需要的校验规则:
export const Config = t.union([ // 分支1:不运行数据库种子任务的合法配置 t.intersection([ BaseConfig, t.type({ RUN_DB_SEEDER: t.literal('false') }), // 如果允许false场景下误传DB_SEED_SIZE不报错,就解开下面这行 // t.partial({ DB_SEED_SIZE: t.string }) ]), // 分支2:运行数据库种子任务的合法配置 t.intersection([ BaseConfig, t.type({ RUN_DB_SEEDER: t.literal('true'), DB_SEED_SIZE: t.string // 该分支下种子配置为强必填 }) ]) ]);
注意:环境变量从进程中读取时全为字符串类型,因此
t.literal必须传字符串格式的'true'/'false',传布尔值会导致校验失效。
这个实现的校验逻辑完全匹配你的需求:
- 传
RUN_DB_SEEDER=true且带DB_SEED_SIZE+ 公共字段 → 校验通过 - 传
RUN_DB_SEEDER=false+ 公共字段 → 校验通过 - 传
RUN_DB_SEEDER=true但缺DB_SEED_SIZE→ 校验直接报错,提示缺失必填字段
同时这个写法完全保留了你要的可维护性:新增全局通用环境变量只需要改BaseConfig,新增其他开关类的依赖规则只需要给t.union追加对应分支即可,不需要改动原有校验逻辑。TS 类型推导也会自动生效,业务代码里判断RUN_DB_SEEDER === 'true'后,TS 会自动缩窄类型,识别到DB_SEED_SIZE一定存在。
复杂场景扩展
如果后续有大量类似「A字段取某值时B字段必填」的规则,可以抽一个可复用的组合工具函数,避免重复写联合结构:
/** * 扩展基础codec,添加条件必填规则 * @param baseCodec 原始基础配置codec * @param flagField 作为判断条件的开关字段名 * @param flagValue 触发必填规则的开关值 * @param requiredField 条件触发时必填的字段名 * @param fieldCodec 必填字段对应的codec */ const requireFieldWhen = < BaseC extends t.Mixed, FlagKey extends keyof t.TypeOf<BaseC>, FlagVal extends t.TypeOf<BaseC>[FlagKey], ReqKey extends string, ReqC extends t.Mixed >( baseCodec: BaseC, flagField: FlagKey, flagValue: FlagVal, requiredField: ReqKey, fieldCodec: ReqC ) => t.union([ t.intersection([ baseCodec, t.type({ [flagField]: t.literal(flagValue) } as Record<FlagKey, t.LiteralC<FlagVal>>), t.type({ [requiredField]: fieldCodec } as Record<ReqKey, ReqC>) ]), t.intersection([ baseCodec, t.refine( t.type({ [flagField]: baseCodec.props[flagField] } as Record<FlagKey, t.Mixed>), (val) => val[flagField] !== flagValue, `flag-not-match-${String(flagValue)}` ) ]) ]);
使用时可以链式叠加规则,不需要修改原有基础配置结构:
// 基础配置 const BaseConfig = t.type({ RUN_DB_SEEDER: t.string, PORT_NUMBER: t.string, RUN_REDIS: t.string, }); // 叠加第一条规则:RUN_DB_SEEDER=true时DB_SEED_SIZE必填 const ConfigWithSeedRule = requireFieldWhen( BaseConfig, 'RUN_DB_SEEDER', 'true', 'DB_SEED_SIZE', t.string ); // 叠加第二条规则:RUN_REDIS=true时REDIS_URL必填 export const Config = requireFieldWhen( ConfigWithSeedRule, 'RUN_REDIS', 'true', 'REDIS_URL', t.string );
如果需要严格校验多余字段(即不允许出现配置里未定义的环境变量),只需要给最终的Config套一层t.exact即可。
内容的提问来源于stack exchange,提问作者Richard Howell-Peak

