如何为非函数参数的动态增长对象添加TypeScript类型定义?
类型安全的动态步骤库改造方案
场景说明
我有一个提供辅助函数的库,用来动态扩展和使用对象,还有若干文件借助该辅助函数定义对象的处理步骤:
// samplestepdefinition.ts interface C { c1: string, c2: string } const isGreaterThanTwo = (number: number): boolean => number > 2 const steps = [ Steps.initialize('a', 3), Steps.provide('b', 'a', isGreaterThanTwo), Steps.provide('c', 'b', (b: boolean): C => ({ c1: 'foo', c2: b ? 'bar' : 'baz' })), Steps.use('c.c2', console.log) ] Steps.processSteps(steps) // logs 'bar'
// stepslib-simplified.ts import lodash_get from 'lodash/fp/get' import lodash_set from 'lodash/fp/set' interface StepContext extends Object { } type StepResolver = (context: StepContext) => StepContext // 实际返回的是修改后的上下文 type ProviderFn = (valueToUse: any) => any type Provide = (keyToSet: string, keyToUse: string, fn: ProviderFn) => StepResolver const provide: Provide = (keyToSet, keyToUse, fn) => (context) => lodash_set(keyToSet, fn(lodash_get(keyToUse, context)), context) type Initialize = (keyToSet: string, value: any) => StepResolver const initialize: Initialize = lodash_set type UserFn = (valueToUse: any) => any type Use = (keyToUse: string, fn: UserFn) => StepResolver const use: Use = (keyToUse, fn) => context => fn(lodash_get(keyToUse, context)) const Steps = { provide, initialize, use, processSteps: (steps: StepResolver[]): void => { steps.reduce((prevContext, step) => step(prevContext), {}) } } export default Steps
问题挑战
当前示例完全没有类型校验:比如把isGreaterThanTwo的参数改成字符串类型,或者第三步写成Steps.provide('c', 'b', (b: number): [...],代码都不会报错。
理想方案
能否仅修改stepslib,让它自动推断所有类型,让StepResolver返回包含新增键值的复合类型?我自行尝试后未找到实现方法。
备选方案
如果无法实现自动推断,能不能让stepdefinition文件将如下自定义泛型传入processSteps以实现类型校验?
interface SpecificContext extends StepContext { a?: number b?: boolean c?: C }
我修改stepslib后遇到类型错误:Argument of type '{}' is not assignable to parameter of type 'Context',求解决办法。
注:可先忽略'c.c2'这类嵌套键场景,优先解决直接键的类型问题。
解决方案
一、理想方案:自动推断类型的实现
通过TypeScript泛型和类型断言,让每个步骤函数追踪上下文的类型变化,最终实现全链路类型校验。修改后的stepslib-simplified.ts如下:
import lodash_get from 'lodash/fp/get' import lodash_set from 'lodash/fp/set' type StepContext = {} // 步骤解析器:接收输入上下文Ctx,返回新增键后的上下文 type StepResolver<Ctx extends StepContext, OutputCtx extends StepContext = Ctx> = (context: Ctx) => OutputCtx // Initialize:新增键值对,泛型约束键名和值类型 type Initialize = <K extends string, V>(keyToSet: K, value: V) => StepResolver<StepContext, StepContext & { [key in K]: V }> const initialize: Initialize = <K extends string, V>(keyToSet: K, value: V) => (context) => lodash_set(keyToSet, value, context) as StepContext & { [key in K]: V } // Provide:基于已有键生成新键,自动推断依赖键的类型 type Provide = <Ctx extends StepContext, K extends keyof Ctx, NewK extends string, V>( keyToSet: NewK, keyToUse: K, fn: (value: Ctx[K]) => V ) => StepResolver<Ctx, Ctx & { [key in NewK]: V }> const provide: Provide = <Ctx extends StepContext, K extends keyof Ctx, NewK extends string, V>( keyToSet: NewK, keyToUse: K, fn: (value: Ctx[K]) => V ) => (context) => lodash_set(keyToSet, fn(lodash_get(keyToUse, context)), context) as Ctx & { [key in NewK]: V } // Use:使用已有键的值,约束回调参数类型 type Use = <Ctx extends StepContext, K extends keyof Ctx>( keyToUse: K, fn: (value: Ctx[K]) => void ) => StepResolver<Ctx> const use: Use = <Ctx extends StepContext, K extends keyof Ctx>(keyToUse: K, fn: (value: Ctx[K]) => void) => (context) => { fn(lodash_get(keyToUse, context)); return context } // processSteps:自动推断步骤链的上下文类型变化 const processSteps = <Ctx extends StepContext>(steps: Array<StepResolver<any, Ctx>>): void => { steps.reduce((prevContext, step) => step(prevContext), {} as StepContext) } const Steps = { provide, initialize, use, processSteps } export default Steps
改造后效果:
- 步骤链的上下文类型会被自动追踪,比如
initialize('a',3)会生成{a: number}的上下文类型 - 后续步骤的依赖键类型会被自动推断,若
isGreaterThanTwo参数改为string,或第三步回调参数写成number,TypeScript会直接报错 - 完全不需要在业务侧手动声明上下文类型,实现无侵入的类型安全
二、备选方案:手动传入上下文类型的实现
如果不需要自动推断,可通过自定义泛型约束上下文,修改stepslib解决空对象类型错误:
import lodash_get from 'lodash/fp/get' import lodash_set from 'lodash/fp/set' interface StepContext {} // 基于自定义上下文的步骤解析器 type StepResolver<Ctx extends StepContext> = (context: Ctx) => Ctx // Initialize:约束键和值匹配自定义上下文 type Initialize<Ctx extends StepContext> = <K extends keyof Ctx>(keyToSet: K, value: Ctx[K]) => StepResolver<Ctx> const initialize: Initialize<any> = <K extends string, V>(keyToSet: K, value: V) => (context) => lodash_set(keyToSet, value, context) // Provide:约束依赖键和新增键都在自定义上下文中 type Provide<Ctx extends StepContext> = <K extends keyof Ctx, NewK extends keyof Ctx>( keyToSet: NewK, keyToUse: K, fn: (value: Ctx[K]) => Ctx[NewK] ) => StepResolver<Ctx> const provide: Provide<any> = <K extends string, NewK extends string, V>( keyToSet: NewK, keyToUse: K, fn: (value: any) => V ) => (context) => lodash_set(keyToSet, fn(lodash_get(keyToUse, context)), context) // Use:约束使用的键存在于自定义上下文中 type Use<Ctx extends StepContext> = <K extends keyof Ctx>( keyToUse: K, fn: (value: Ctx[K]) => void ) => StepResolver<Ctx> const use: Use<any> = <K extends string>(keyToUse: K, fn: (value: any) => void) => (context) => { fn(lodash_get(keyToUse, context)); return context } // processSteps:用Partial<Ctx>兼容初始空对象,再断言为Ctx const processSteps = <Ctx extends StepContext>(steps: StepResolver<Ctx>[]): void => { steps.reduce((prevContext, step) => step(prevContext), {} as Partial<Ctx> as Ctx) } const Steps = { provide, initialize, use, processSteps } export default Steps
业务侧使用方式:
interface C { c1: string, c2: string } interface SpecificContext extends StepContext { a?: number b?: boolean c?: C } const isGreaterThanTwo = (number: number): boolean => number > 2 const steps: Array<StepResolver<SpecificContext>> = [ Steps.initialize('a', 3), Steps.provide('b', 'a', isGreaterThanTwo), Steps.provide('c', 'b', (b: boolean): C => ({ c1: 'foo', c2: b ? 'bar' : 'baz' })), Steps.use('c', ctx => console.log(ctx.c2)) ] Steps.processSteps<SpecificContext>(steps)
核心解决点:将初始上下文声明为Partial<Ctx> as Ctx,因为空对象{}符合所有键可选的Partial<SpecificContext>,再通过类型断言转换为目标上下文类型,解决空对象赋值的类型错误。
内容的提问来源于stack exchange,提问作者KonstantinK
相关产品推荐
相关产品推荐

