如何推断 TypeScript 中的可选属性?

How can I infer optional properties in TypeScript?

我有以下数据库模式的类型定义:

type Schema<T> = {
  [K in keyof T]: SchemaType<T[K]>
}

interface SchemaType<T> {
  optional: boolean
  validate(t: T): boolean
}

这是一个实现它的对象:

const userSchema = {
  name: {
    optional: true,
    validate(name: string) {
      return name.length > 0 && name.length < 30
    }
  }
}

我希望能够根据 userSchema 对象推断 Schema 中的泛型类型 T。使用 TypeScript 的 infer 关键字无需太多努力就可以做到这一点:

type extractType<T> = T extends Schema<infer X> ? X : null

// User is inferred as { name: string }
type User = extractType<typeof userSchema>

更好的是,我能够间接推断出 User 类型:

class Model<T> {
  constructor(private schema: Schema<T>) {}
}

// userModel is inferred as type Model<{name: string}>
const userModel = new Model(userSchema)

但是,我希望能够将 User 推断为 { name?: String },其中 name 是可选的,因为它的 optional 属性 设置为真的。有办法吗?

一些相关的 TypeScript 功能

这是我在处理此问题时 运行 遇到的一些可能有用的 TypeScript 功能。

  1. TypeScript 有discriminated unions,这与我试图实现的类型推断类似。

  2. 虽然上面 userSchema 上的 optional 字段被推断为 boolean,但可以使用 [=28= 将其推断为布尔文字].

    // userSchema is inferred as
    // { name: { optional: true, validate(name: string): boolean } }
    // instead of
    // { name: { optional: boolean, validate(name: string): boolean } }
    const userSchema = {
      name: {
        optional: true as const,
        validate(name: string) {
          return name.length > 0 && name.length < 30
        }
      }
    }
    

    要求这样做并不理想,但我不确定它是否可以避免。

  3. 我尝试像上面的 extractType 实现中那样使用 conditional types 以更有限的方式定义 Schema

    // The below interface essentially says:
    //   if T[K] is an optional property
    //     then -> optional is set to true
    //     else -> optional is set to false.
    // I was hoping TypeScript could reverse this when inferring the type:
    //   if optional is set to true
    //     then -> T[K] is an optional property
    //     else -> T[K] is a required property
    type Schema<T> = {
      [K in keyof T]: undefined extends T[K]
        ? OptionalSchemaType<T[K]> // Same as SchemaType but with optional: true
        : RequiredSchemaType<T[K]> // Same as SchemaType but with optional: false
    }
    
    const userModel = new Model(userSchema) // Error!
    

    不幸的是,TypeScript 的类型推断不够智能,无法推断出如此复杂的类型。

我不确定我想要实现的目标是否可行,但非常感谢任何帮助或替代方案。 :)

首先,您可能需要编写 true as consttrue as true 或类似的东西,因为编译器会倾向于将布尔文字扩展为 boolean 类型,除非它们在编译器期望更窄类型的上下文。避免显式转换的一种可能方法是使用一个辅助函数,该函数 returns 它的输入期望在这样一个更窄类型的上下文中:

const asSchema = <S extends Record<keyof S, { optional: B }>, B extends boolean>(
    s: S
) => s;

const userSchema = asSchema({
    name: {
        optional: true,
        validate(name: string) {
            return name.length > 0 && name.length < 30
        }
    },
    age: {
        optional: false,
        validate(age: number) {
            return age >= 0 && age < 200;
        }
    }
});

你可以验证userSchema的类型是:

/* const userSchema: {
    name: {
        optional: true;
        validate(name: string): boolean;
    };
    age: {
        optional: false;
        validate(age: number): boolean;
    };
} */

如果您打算创建很多这样的东西,那么使用辅助函数可能是值得的;或者你可以只写 optional: false as false。无论如何,继续前进:


我认为您需要直接计算类型而不是依赖类型推断。直接方法需要大量明确地写出类型的哪些部分会变成什么。让我们提出一些实用程序类型:

PartialPartial<T, K> 类型采用一个类型 T 及其一组键 K 和 returns 一个类似于 T 的新类型,除了所有的K 索引的属性是可选的。所以,PartialPartial<{a: string, b: number}, "a">{a?: string, b: number}.

type PartialPartial<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K> extends
    infer O ? { [P in keyof O]: O[P] } : never;

KeysMatching<T, V> 类型采用类型 T 和 returns T 的所有键,其属性可分配给 V。所以,KeysMatching<{a: string, b: number}, number>"b".

type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];

现在 ExtractType<T>。给定一个类似模式的 T,首先映射它的每个属性并将第一个参数拉出到 validate() 方法。然后,取整个结果,并将 optional 属性 为 true:

的所有属性设为可选
type ExtractType<T extends { [K in keyof T]: SchemaType<any> }> = PartialPartial<{
    [K in keyof T]: Parameters<T[K]['validate']>[0]
}, KeysMatching<T, { optional: true }>>

在上面的 userSchema 上进行测试:

type UserThing = ExtractType<typeof userSchema>;
/* type UserThing = {
    name?: string | undefined;
    age: number;
} */

看起来不错。


如果我们放弃类型推断,这意味着您不能轻易地让 Model<T>Schema<T> 构造(至少不能使用标准 class 语句)。解决这个问题的一种方法是让 Model 在模式类型 S 中是通用的,并在任何您想使用 T 的地方使用 ExtractType<S>。您甚至可以将 T 放在类型定义中,因此您有 Model<S, T>,其中 S 是架构类型,T 是提取的类型:

class Model<S extends Record<keyof S, SchemaType<any>>, T extends ExtractType<S> = ExtractType<S>> {
    constructor(private schema: S) { }
}

const userModel = new Model(userSchema);
// S is typeof userSchema
// T is {name?: string, age: number}

我对标准 class 语句的警告:您可以决定通过 ModelConstructor 接口来描述 Model 的静态方面,该接口允许您指定与constructor 方法和实例类型之间通常可用的是什么:

interface MyModelConstructor {
    new <S extends Record<keyof S, SchemaType<any>>>(schema: S): MyModel<ExtractType<S>>;
}
interface MyModel<T> {
    something: T;
}
const MyModel = Model as any as MyModelConstructor;

并测试它:

const myUserModel = new MyModel(userSchema);
/* const myUserModel: MyModel<{
    name?: string | undefined;
    age: number;
}> */

在这里你可以看到 new MyModel(userSchema) 产生了 MyModel<{name?: string, age: number}>。这很好,但是您会发现自己必须为 class 的 static/instance 端写出冗余接口声明,然后将另一个 class 构造函数分配给它。复制可能不值得;由你决定。


好的,我知道有很多东西。希望对你有帮助。祝你好运!

Playground link to code