TypeScript嵌套数组可空属性转可选工具类型失效问题
TypeScript工具类型无法处理自定义类型数组嵌套可空属性的问题修复
问题描述
- 编写了一个TypeScript工具类型,目标是将类型中所有可空属性(包括多层嵌套的可空属性)转换为可选属性
- 异常表现:当属性值为其他自定义类型数组时,工具类型无法正常生效;但数组结构直接内联展开定义在父类型中时,工具类型可以正常运行
原有类型实现
// 判断T是否包含null类型,是则返回null,否则返回原类型 type ExtractNull<T> = Extract<T, null> extends null ? Extract<T, null> extends never ? T : null : T // 提取T中所有值可空的键名联合类型 type NullKeys<T extends Record<PropertyKey, any>> = { [K in keyof T]: ExtractNull<T[K]> extends null ? K : T[K] extends Record<PropertyKey, any> ? NullKeys<T[K]> : never }[keyof T] // 将所有可空属性转为可选属性 export type OptionalNulls<T extends Record<PropertyKey, any>> = { [K in keyof T as Exclude<K, NullKeys<T>>]: T[K] extends Record< PropertyKey, any > ? OptionalNulls<T[K]> : T[K] } & Partial<{ [K in keyof T as Extract<K, NullKeys<T>>]: T[K] }>
测试用例
测试预期:所有值为null | 其他类型的可空属性,无论嵌套多深、是否在数组中,都要被转为可选属性。
type TestRoot = { prop0: null | string testObj: TestObj testObjArray: TestObj[] testObjArrayUnwrapped: [ { prop1: null | string arr: [ { prop2: null | string } ] } ] } type TestObj = { prop1: null | string arr: [ { prop2: null | string } ] } const test: OptionalNulls<TestRoot> = { prop0: "optional", testObj: { prop1: "optional", arr: [{ prop2: "optional" }], }, // 以下属性内的可空属性未被转为可选,不符合预期 testObjArray: [{ prop1: "not optional", arr: [{ prop2: "not optional" }] }], // 内联数组下的可空属性正常转为可选,符合预期 testObjArrayUnwrapped: [ { prop1: "optional", arr: [ { prop2: "optional", }, ], }, ], }
缺陷原因
原有类型存在两个核心问题:
- 递归逻辑分支缺失:仅对匹配
Record<PropertyKey, any>的普通对象类型做嵌套递归处理,完全没有覆盖数组、元组类型。当属性值为TestObj[]这类命名类型数组时,会直接返回原数组类型,不会深入遍历数组元素的内部结构,导致元素内的可空属性没有被转换。 - 内联元组生效属于偶然行为:TypeScript对直接内联在父类型中的元组字面量会做隐式的上下文类型推导,自动递归遍历结构,因此看起来符合预期;但提前定义的命名类型数组不会触发这个隐式推导,就会出现表现不一致的问题。
修复方案
新增统一的嵌套类型处理逻辑,覆盖数组、元组、普通对象三类嵌套场景,同时调整可空键的提取逻辑,将数组/元组纳入递归遍历范围:
type ExtractNull<T> = Extract<T, null> extends null ? Extract<T, null> extends never ? T : null : T // 统一处理所有嵌套类型:数组、元组、普通对象 type ProcessNested<T> = // 处理普通数组 T extends Array<infer U> ? Array<ProcessNested<U>> // 处理元组 : T extends readonly [infer First, ...infer Rest] ? readonly [ProcessNested<First>, ...{ [K in keyof Rest]: ProcessNested<Rest[K]> }] // 处理普通对象 : T extends Record<PropertyKey, any> ? OptionalNulls<T> // 基础类型直接返回 : T type NullKeys<T extends Record<PropertyKey, any>> = { [K in keyof T]: ExtractNull<T[K]> extends null ? K // 把数组、元组也纳入可空键的递归判断范围 : T[K] extends Record<PropertyKey, any> | Array<any> | readonly any[] ? NullKeys<ProcessNested<T[K]> extends Record<PropertyKey, any> ? ProcessNested<T[K]> : never> : never }[keyof T] export type OptionalNulls<T extends Record<PropertyKey, any>> = { [K in keyof T as Exclude<K, NullKeys<T>>]: ProcessNested<T[K]> } & Partial<{ [K in keyof T as Extract<K, NullKeys<T>>]: ProcessNested<T[K]> }>
修复后testObjArray属性下的prop1、prop2等嵌套可空属性会被正确转为可选,和内联数组的表现完全一致。
内容的提问来源于stack exchange,提问作者ne3zy
相关产品推荐
相关产品推荐

