从配置对象中获取动态类型定义的最佳方法在TypeScript中是什么?

3

我有一个用普通Javascript编写的系统,其中可以使用类型作为方法覆盖定义模型,并在数据进入时进行转换,但是想要纯粹基于赋值获得typescript的好处(而不是实例化,或者至少不那么重要)。

typescript应该是配置字典/纯对象的产品。是否有一种动态定义以下模式的方法?我确实看到了一些定义字典的示例,但我想对任何模型执行此操作(这仍需要代码复制)

我看到可能可以制作一个typescript插件或进行接口代码生成。在四处查找之后,我没有看到任何类似的答案。只是想知道是否有人对完成最佳方式有任何想法或者它根本不可能。

从属性动态生成类型?

用户使用模型.模型是我们的基本模型类,使用公共属性和类型来进行转换并设置诸如readonly或any之类的东西。需要添加typescript,但基本上是从真相源易于推导出自己的重复。

export class User extends Model {
  // can i automate the process of generating this below from
  // definePublicAttributes
  firstName?: string;
  lastName?: string;
  email?: string;
  state?: string;
  countryCode?: string;
  lookupCreditBalance?: number;
  creditTime?: Date;

  // this will pull in andd assign to the instance when data dict
  // is passed..can this be read or provided some way 
  definePublicAttributes(any) {
    return {
      first_name: String,
      last_name: String,
      email: String,
      state: String,
      country_code: String,
      lookup_credit_balance: Number,
      create_time: Date,
      is_anonymous: any.readOnly,
      profile_url: any.readOnly,
    }
  }

为了提供一个完整的最小可重现示例,请参见我正在进行的虚构简化版本。

(附注:请注意,除了初始转换之外,模型系统在应用程序的整个生命周期中用于其他事情,例如更改,但出于简单起见而省略)

最小可重现示例

两个模型(PetPerson)扩展基本Model,从嵌套的纯对象和模型类型定义实例化。我只想让TypeScript在编辑时防止错误的类型分配和文档,但可能不需要显式编写类型定义,而是从属性/方法/源(如get publicAttributes())获取它。

class Model {
  get publicAttributes() {
    return {}
  }
  
  cast(value, type=null) {
    if (type === Date) {
      return new Date(value);
    } else if (type === Number) {
      return Number(value);
    } else if (type === String) {
      return value.toString();
    } else if (Model.isModel(type)) {
        const model = new type(value);
        return model;
    } else if (Array.isArray(type)) {
        return value.map((item) => {
          return this.cast(item, type[0]);
        });
    } else if (typeof type === 'function') {
      // if function, invoke and pass in
      return this.cast(value, type(value))
    }
    return value;
  }
  
  static isModel(val) {
    return (
      val?.prototype instanceof Model ||
      val?.constructor?.prototype instanceof Model
    );
  }
  
  constructor(data={}) {
    for (let [key, value] of Object.entries(data)) {
      this[key] = this.cast(value, this.publicAttributes[key] || null);
    }
  }
}

class Pet extends Model {
  // can below be progammatically derived (?)
  name?: string
  type?: string
  
  get publicAttributes() {
   return {
      name: String,
      type: String,
    }
  }
}

class Person extends Model {
   // can below be progammatically derived (?)
  joined?: Date
  name?: string
  age?: number
  skills?: Array<string>
  locale?: any
  pets?: Array<Pet>
  
  get publicAttributes() {
    return {
      joined: Date, // primative
      name: String, // primative
      age: Number, // primative
      skills: [String], // primative arr
      locale: (v) => v === 'vacation' ? null : String, // derived type via fn
      pets: [Pet], // model arr   
    }
  }
}


const bill = new Person({
   joined: '2021-02-10',
   name: 'bill',
   age: '26',
   skills: ['python', 'dogwalking', 500],
   locale: 'vacation',
   pets: [
      {
        name: 'rufus',
        type: 'dog',
      },
   ]
});

console.log(bill);

1
可能不是你想要的,但你可以使用 <T> is-it-possible-to-auto-generate-class-properties-from-its-generic-type,并且 T 将作为接口 definePublicAttributes 的定义 :-? - Mara Black
1
这种方法是否满足您的需求?如果满足,我可以写出我的答案并解释;如果不满足,那么我缺少什么?(请注意,为了让您的代码成为[mre],我不得不在“Model”中添加许多“any”注释以抑制我认为与您的问题无关的干扰性错误。如果我对此正确,您是否可以在问题中添加相同的注释到“Model”中?如果我错了,具体是哪里出错了?哦,请再次提及@jcalz,以便在回复时提醒我) - jcalz
1
@jcalz 真遗憾,这种方法不起作用。 - Bergi
1
@jcalz 您太棒了,这个真的管用!或许您能在写作中解释一下不同区域所发生的事情,例如publicAttributes: T } & Model & Partial<_MFA<T>>;,或者我目前可能对 TypeScript 还不太熟悉哈哈。 - c.m
1
jcalz(无提及):不,我的意思是关注按钮,它应该会让你收到每个问题的新评论通知。 - Bergi
显示剩余10条评论
1个回答

4
我能想到的唯一实现方式是编写一个通用类工厂函数(我们称之为ModelFromAttributes()),它以泛型类型TpublicAttributes值作为输入,并将适当的Model子类作为其输出产生,其类型由T确定。 TpublicAttributes类型)和输出类的实例类型之间的关系有些复杂,因此我们需要定义一个辅助类型(我们也称之为type ModelFromAttributes<T>)来捕获这个关系。
因此,我们需要像下面这样的东西:
declare function ModelFromAttributes<T extends object>(
    atts: T): new (args?: any) => ModelFromAttributes<T>;

type ModelFromAttributes<T extends object> = /* impl here */;

而我们将使用它像这样

class Pet extends ModelFromAttributes(
    {
        name: String,
        type: String
    }
) { }


class Person extends ModelFromAttributes({
    joined: Date, 
    name: String, 
    age: Number, 
    skills: [String], 
    locale: (v: any) => v === 'vacation' ? null : String, 
    pets: [Pet],
}) { }

因此,ModelFromAttributes({...}) 接受类型为 T 的输入,并生成一个带有 构造函数签名 的输出,其实例类型为 ModelFromAttributes<T>。那么它是什么类型?

嗯,我们希望 ModelFromAttributes<T> 具有类型为 TpublicAttributes 属性,并且它也应该是一个 Model,因为它将继承自 Model,并且它还应该具有一些由 T 某种方式确定名称和类型的可选属性。因此,它应该看起来像:

type ModelFromAttributes<T extends object> =
    { publicAttributes: T } & Model & Partial<MFA<T>>;

在这里,我们使用 Partial<X> 辅助类型将所有属性转换为可选属性,并且MFA<T>是一种尚未定义的类型,需要表示将属性的类型T转换为相应类属性的类型MFA<T>交集运算符(&可以被理解为“和”,一个ModelFromAttributes<T>对象具有一个T类型的publicAttributes属性,它是一个Model,并且它是MFA<T>的全部可选版本,无论那是什么。


好的,现在我们需要定义MFA<T>。这里是一个可能的定义:

type MFA<T> =
    T extends typeof Date ? Date :
    T extends typeof String ? string :
    T extends typeof Number ? number :
    T extends typeof Boolean ? boolean :
    T extends abstract new (...args: any) => infer R ? R :
    T extends (...args: any) => infer R ? MFA<R> :
    T extends object ? { [K in keyof T]: MFA<T[K]> } :
    T; 

这是一个条件类型,它检查T并根据检查结果评估为不同的内容。

所以,如果T可以分配给typeof Date(使用类型级别的typeof类型运算符来描述名为Date的值的类型),那么MFA<T>应该是Date。如果TString构造函数的类型,则MFA<T>应该是string。对于数字和布尔类型也是如此。

如果T是其他类构造函数类型,则MFA<T>应该是该类的实例类型(使用条件类型推断来提取该类类型)。

如果T是其他函数类型,则MFA<T>应该以该函数的返回类型开头,但由于返回类型本身可能类似于StringDate构造函数,因此我们需要递归地将MFA<>应用于该类型。也就是说,MFA<T>是一个递归条件类型

最后,如果T是其他对象类型,则MFA<T>映射类型,对于T中具有键K的每个属性,我们取属性值类型T[K]并将MFA<>应用于它本身。因此,这是另一个递归步骤。还要注意,数组/元组上的映射类型会产生数组/元组,因此在数组上应该自动按预期工作,无需额外工作。


在继续之前,让我们确保它在您的PetPerson属性类型上表现如预期:
const petAttributes = {
    name: String,
    type: String
};
type PetModelProps = MFA<typeof petAttributes>
/* type PetModelProps = {
    name: string;
    type: string;
} */

目前看起来不错。我们还没有一个Pet类的构造函数,所以让我们用手工制作的接口来描述一个应该是相同的东西:

interface PetModel extends Partial<PetModelProps>, Model {
    publicAttributes: PetModelProps
}
const personAttributes = {
    joined: Date,
    name: String,
    age: Number,
    skills: [String],
    locale: (v: any) => v === 'vacation' ? null : String,
    pets: [null! as new () => PetModel], // just for typing purposes
}

type PersonModelProps = MFA<typeof personAttributes>
/* type PersonModelProps = {
    joined: Date;
    name: string;
    age: number;
    skills: string[];
    locale: string | null;
    pets: PetModel[];
} */

也看起来不错。


好的,我们现在需要做的就是实际实现 ModelFromAttributes()。像这样:

function ModelFromAttributes<T extends object>(atts: T) {
    return class extends Model {
        get publicAttributes() { return atts }
    } as any as new (args?: any) => ModelFromAttributes<T>;
}

我在这里使用了类型断言来抑制编译器错误。编译器无法理解类表达式实际上构造了ModelFromAttributes<T>的实例;像ModelFromAttributes<T>这样的通用条件类型,其中T尚未指定,对编译器来说基本上是不透明的。一般来说,编译器无法查看类声明或表达式并推断出动态属性键;类生成接口类型,而接口只能具有静态已知的属性名称。因此,类似于类型断言这样的东西是必要的。
所有这些意味着我们需要小心,确保我们的实现做到我们所说的那样。

那么让我们来试试:

class Pet extends ModelFromAttributes(
    {
        name: String,
        type: String
    }
) { }

class Person extends ModelFromAttributes({
    joined: Date,
    name: String,
    age: Number,
    skills: [String],
    locale: (v: any) => v === 'vacation' ? null : String,
    pets: [Pet],
}) { }    

const bill = new Person({
    joined: '2021-02-10',
    name: 'bill',
    age: '26',
    skills: ['python', 'dogwalking', 500],
    locale: 'vacation',
    pets: [
        {
            name: 'rufus',
            type: 'dog',
        },
    ]
});

所有内容都可以无误编译。让我们使用IntelliSense检查bill属性的类型:

bill.age // (property) age?: number | undefined
bill.cast // (method) Model.cast(value: any, type?: any): any
bill.joined // (property) joined?: Date | undefined
bill.locale // (property) locale?: string | null | undefined
bill.name // (property) name?: string | undefined
bill.pets // (property) pets?: Pet[] | undefined
bill.publicAttributes /* (property) publicAttributes: {
    joined: DateConstructor;
    name: StringConstructor;
    age: NumberConstructor;
    skills: StringConstructor[];
    locale: (v: any) => StringConstructor | null;
    pets: (typeof Pet)[];
} & {} */
bill.skills // (property) skills?: string[] | undefined

console.log(bill.pets?.[0].type?.toUpperCase()) // "DOG"

看起来不错!bill的属性包括Model的所有属性,加上强类型的publicAttributes类型,以及对应于MFA<>的所有可选属性。

代码游乐场链接


哇,太棒了,感谢你的详细说明!这将非常有用。 - c.m
抱歉,问一下,我现在正在实现这个功能,对于基类来说效果很好,但我想知道是否可以以某种方式将T作为模型方法的返回签名,带有选项的话。所以,我想要 toCamelPlainObject(): CasedFlatInterface,其中 CaseFlatInterface 将是模式的扁平化/变形版本。我猜想一个操作是将其扁平化,另一个是更改大小写,那么如何影响模型的方法呢? - c.m
我不能没有更多信息就做出判断,而且在旧问题的评论区也不是探讨这个问题的好地方。也许你可以发布一个新问题,我可能能够提供帮助(或者我可能无法提供帮助,但是在那里可能会有其他人看到并提供帮助,而不仅仅是我)。 - jcalz
我刚才正在结束我所说的内容(添加了两个与此问题非常相关的示例接口)。如果太多了,我可以重新开一个问题。playground link - c.m
这个评论区并不是一个适合追问问题的地方。 - jcalz
1
感谢@jcalz。已理解,如果有兴趣,已发布了专门的后续问题,请查看:https://dev59.com/6H8QtIcB2Jgan1znzhyS - c.m

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接