如何移除TS中的any并让getValue推断Schema键?配置工具多项目复用
问题背景
我正在重构一个供多子项目共享的配置工具库(发布为@example/example),目标是让getValue函数具备键名自动补全能力。当前代码使用了any类型,想要替换掉;同时希望每个子项目初始化配置后,整个项目中调用getValue时,TS能自动识别传入Schema的键。
每个子项目处于独立环境,另一种思路是给每个子项目单独编写适配器,这种方案可行吗?
此外,我尝试用unknown代替any,但无法给变量重新赋值,这是为什么?如果这是TypeScript的特性限制,能否解释原因并提供更DRY的最佳实践?
原始库代码
// Minimal set and get configuration provider functionalities, pass in a schema and get by key interface ConvictLike<T> { get: (keyName: keyof T) => T[keyof T] | undefined validate: () => void } type Schema<T> = { [P in keyof T]: T[P] } const convictLike = <T>(obj: Schema<T>): ConvictLike<T> => { return { get: (keyName: keyof T) => { if (obj && typeof obj === 'object') { return keyName in obj ? obj[keyName as keyof T] : undefined } throw new Error('Invalid object') }, validate: () => {}, } } // TODO so get can infer keys // eslint-disable-next-line @typescript-eslint/no-explicit-any let configurationProvider: ConvictLike<any> | undefined export function initializeAndValidate<T>(schema: T) { configurationProvider = convictLike<T>(schema) configurationProvider.validate() } export function getValue(keyName: string) { if (!configurationProvider) { throw new Error('Configuration has not been initialized yet') } return configurationProvider.get(keyName) }
子项目使用示例
import { getValue, initializeAndValidate } from './provider' // or '@example/example' initializeAndValidate({ test: { doc: 'test', format: String, default: 'test' } }) console.log(getValue('test'))
为什么unknown代替any会赋值失败?
unknown是TypeScript中最严格的顶层类型,它的兼容性规则和any完全不同:
any可以和任意类型双向兼容,相当于关闭了类型检查;unknown只能被赋值给unknown或any类型,反过来,其他类型不能直接赋值给unknown。
当你把configurationProvider声明为ConvictLike<unknown> | undefined时,convictLike<T>(schema)返回的ConvictLike<T>无法赋值给它——因为TS无法确认具体的T类型是否和unknown兼容,这种场景下unknown的严格性反而成为了障碍。
解决方案与最佳实践
方案1:工厂函数模式(推荐,DRY且适配多子项目)
将原库改成工厂模式,让每个子项目生成独立的配置实例,实例的类型会绑定到当前子项目的Schema,完全消除any:
// provider.ts(修改后的库代码) interface ConvictLike<T> { get: (keyName: keyof T) => T[keyof T] | undefined validate: () => void } type Schema<T> = { [P in keyof T]: T[P] } const createConvictLike = <T>(obj: Schema<T>): ConvictLike<T> => { return { get: (keyName: keyof T) => { if (obj && typeof obj === 'object') { return keyName in obj ? obj[keyName as keyof T] : undefined } throw new Error('Invalid object') }, validate: () => {}, } } export function createConfigurationProvider<T>() { let provider: ConvictLike<T> | undefined return { initializeAndValidate: (schema: T) => { provider = createConvictLike(schema) provider.validate() }, getValue: (keyName: keyof T) => { if (!provider) { throw new Error('Configuration has not been initialized yet') } return provider.get(keyName) } } }
子项目使用方式:
// 子项目代码 import { createConfigurationProvider } from '@example/example' // 声明当前子项目的Schema类型 type AppConfig = { test: { doc: string; format: typeof String; default: string } } // 生成当前项目专属的配置工具 const { initializeAndValidate, getValue } = createConfigurationProvider<AppConfig>() initializeAndValidate({ test: { doc: 'test', format: String, default: 'test' } }) console.log(getValue('test')) // 这里会自动补全键名,且类型安全
这种方式让每个子项目的配置类型完全独立,不需要重复编写冗余代码,是最符合DRY原则的方案。
方案2:子项目单独编写适配器(可行,无需修改原库)
如果无法修改原库,每个子项目可以自己封装一层适配器,显式声明Schema类型,实现类型安全:
// 子项目的config/adapter.ts import { initializeAndValidate as baseInit, getValue as baseGet } from '@example/example' // 声明当前子项目的Schema类型 type AppConfig = { test: { doc: string; format: typeof String; default: string } } export const initializeAndValidate = (schema: AppConfig) => baseInit(schema) export const getValue = (keyName: keyof AppConfig) => baseGet(keyName) as AppConfig[keyof AppConfig]
子项目中直接导入自己的适配器即可,虽然每个子项目要写重复代码,但实现简单,能满足类型安全和自动补全的需求。
内容的提问来源于stack exchange,提问作者fyt1th

