【问题标题】:Creating legible TypeScript classes from a class factory function从类工厂函数创建清晰的 TypeScript 类
【发布时间】:2020-10-16 00:22:04
【问题描述】:

我有一个第三方库,它返回包含对打字稿不透明的字段的对象(类型 OpaqueObject)。对于任何给定的OpaqueObject,我知道定义和/或需要哪些字段,但 Typescript 一无所知。给定OpaqueObject 的“模式”仅列出定义的字段,并带有一个布尔标志,指示它们是否需要存在:

type OpaqueObjectSchema = { [field: string]: boolean }

通过getField 函数从OpaqueObject 检索字段。 getField 使用类型谓词来调整返回类型,具体取决于是否需要该字段。获取不存在的必填字段会引发错误。这是签名:

getField<B extends boolean>(
  obj: OpaqueObject, field: string, isRequired: B,
): B extends true ? string : (string | undefined)

我有一个类工厂函数 (opaqueObjectWrapperFactory),它接受 OpaqueObjectSchema 并输出一个包装类。例如,我有一个 OpaqueObject 类型,它有两个字段:f1f2f1 是必需的;f2 可能未定义。我工厂生产的类的硬编码版本是:

class OpaqueObjectWrapper {

  private obj: OpaqueObject

  constructor(obj: OpaqueObject) {
    this.obj = obj;
  }

  get f1(): string { return getField(this.obj, 'f1', true) }
  get f2(): string | undefined { return getField(this.obj, 'f2', false) }
}

// OpaqueObjectWrapper is equivalent to the return value of:
opaqueObjectWrapperFactory({
  f1: true,
  f2: false,
});

我的问题是我无法弄清楚如何使这些生成的类对 Typescript 清晰可见。工厂应该这样工作:

// this declaration should have the same effect as hardcoding `OpaqueObjectWrapper`
class OpaqueObjectWrapper extends opaqueObjectWrapperFactory({ f1: true, f2: false });

显然我需要以某种方式使用泛型,但我不确定如何从输入模式派生返回接口。这可能吗?

【问题讨论】:

  • get f1() 中应该是true,对吧?
  • @ShivamSingla 是的,抱歉,已修复。

标签: typescript


【解决方案1】:

检查这是否适合你-

declare function getField<B extends boolean>(
  obj: OpaqueObject, field: string, isRequired: B,
): B extends true ? string : (string | undefined)

type OpaqueObject = any

type OpaqueObjectSchema = {
    [k: string]: boolean
}

// get the required keys
// e.g type KlassRequiredKeys = 'f1'
type KlassRequiredKeys<S extends OpaqueObjectSchema> = ({
    [K in keyof S]: S[K] extends true ? K : never
})[keyof S]

// get the optional keys
// e.g type KlassOptionalKeys = 'f2'
type KlassOptionalKeys<S extends OpaqueObjectSchema> = ({
    [K in keyof S]: S[K] extends true ? never : K
})[keyof S]

// create two objects one with required keys and other with optional keys,
/// then merge them
type Klass<S extends OpaqueObjectSchema> = {
    [K in KlassRequiredKeys<S>]: string
} & {
    [K in KlassOptionalKeys<S>]?: string
}

// type constructor function returnd by the factory function
type Constructor<P extends OpaqueObjectSchema> = {
    new (obj:OpaqueObject): Klass<P>
}

function factory<S extends OpaqueObjectSchema>(schema: S): Constructor<S> {
    const ctor2 = class {
        _obj: OpaqueObject // private
        constructor(obj: OpaqueObject) {
            this._obj = obj
        }
    } as unknown as Constructor<S>

    for (let key in schema) {
            Object.defineProperty(ctor2.prototype, key, {
                get() {
                    return getField(
                        this._obj,
                        key,
                        schema[key]
                    )
                }
            })
    }
    return ctor2
}

const schema = {
    f1: true,
    f2: false,
} as const

type X = typeof schema

const OpaqueObjectWrapper = factory(schema)

const a1 = new OpaqueObjectWrapper({}) // ok
const a2 = new OpaqueObjectWrapper({f1: 343}) // ok
const a3 = new OpaqueObjectWrapper({f1: '343'}) // ok
const a4 = new OpaqueObjectWrapper({f1: '343', f2: 34}) // ok
const a5 = new OpaqueObjectWrapper({f1: '343', f2: '34'}) // ok

a3.f1 // string
a3.f2 // string | undefined

a3._obj // error

一个问题是getField 的签名几乎没有受到影响,尽管工厂函数返回的类运行良好

Playground

【讨论】:

  • 感谢您的回答。这几乎就在那里,但有几个问题:(1)OpaqueObjects 不要将它们的字段存储在对象属性中(这是一个自定义数据结构)。因此构造函数的输入类型不应该是Klass&lt;P&gt;,而应该是OpaqueObject; (2) 因此,您不需要提供getField 的实现(仅签名就足够了); (3) 也是因为第 1 点,OpaqueObjectWrapper 在传递不正确的对象时不会抛出任何错误。重要的是它定义了正确的吸气剂(你做的)。解决这些问题,我会接受!
  • 你确定a3.f1 有字符串类型吗?我相信类型实际上是 string | undefined,因为 Typescript 类型 schema[key]boolean 而不是 true。我现在有一个可行的实现,我通过在schema[key] 上使用if 并在两个分支中定义具有不同签名的getter 来解决这个问题。这很丑,但它有效。我更喜欢更紧凑的东西(就像你一样),但我认为这是不可能的。
  • @SeanMackesey,是的,它的string。检查操场。请注意,我在定义架构时使用了as const,这就是为什么f1f2 的类型分别为truefalse
  • 嗯,看来你是对的!我猜这是因为定义的getter函数的返回类型实际上并不重要,因为它被a3Klass&lt;X&gt;)的断言接口覆盖。谢谢!
  • 很高兴它有帮助 :) 关于 SO 的问题、回答和讨论确实是学习事物的好方法。
【解决方案2】:

派生对象类型

OpaqueObject 是其中所有值都是 string 并且某些键是必需的而其他键是可选的。让我们首先定义一个类型,假设我们已经知道哪个是哪个:

type OpaqueObject<AllKeys extends string, RequiredKeys extends string> = Partial<Record<AllKeys, string>> & Record<RequiredKeys, string>

如果我们想从一个模式转到一个对象,我们知道我们需要找到所有的键——这很容易,因为它只是 keyof Schema——以及所需的键。我们知道,如果需要,如果模式中的值是 true,则键。

重要提示:我们必须在创建架构时使用as const,以便将truefalse 分开,否则我们只知道我们有一个boolean

架构S 所需的键是:

type RequiredKeys<S> = {
    [K in keyof S]: S[K] extends true ? K : never;
}[keyof S]

所以我们现在可以为 OpaqueObject 编写一个类型,它只依赖于架构 S

type OpaqueObject<S> = Partial<Record<keyof S, string>> & Record<RequiredKeys<S>, string>

获取字段

现在转到getField 函数。我们不想传入一个 boolean required 标志,因为我们应该已经知道这一点。相反,让我们让它依赖于泛型架构S 和键K

function getField<S, K extends keyof S>(
    obj: OpaqueObject<S>, field: K
): OpaqueObject<S>[K] {
    return obj[field];
}

但老实说,如果我们有一个正确类型的对象,那么整个函数就变得不必要了,因为我们可以通过直接访问属性获得正确的返回类型。

const exampleSchema = {
    f1: true,
    f2: false,
} as const;

type ExampleObject = OpaqueObject<typeof exampleSchema>

class OpaqueObjectWrapper {

  private obj: ExampleObject

  constructor(obj: ExampleObject) {
    this.obj = obj;
  }

  get f1(): string { return this.obj.f1 }
  get f2(): string | undefined { return this.obj.f2 }
}

未知数

关于as const 是否可能,我对模式的来源感到困惑。这些是来自外部来源的变量吗?还是通过在代码中写出对象来定义它们?

让我对你的问题感到困惑的部分是在类中以动态方式实现 get f1()get f2()。与 PHP 等其他语言不同,Javascript 没有属性值未知的动态 getter。您只能通过Proxy 进行操作。

代理对象

我知道如何动态获取属性的唯一方法是使用Proxy。我已经解决了这个问题。我仍然缺少的部分是如何在代理的伪类上实现构造签名。

这个代理接受一个类的实例,该实例将对象存储为属性obj,并允许我们直接访问obj 的属性。为了让 typescript 能够理解添加的属性,我们必须断言 as Constructable&lt;T&gt; &amp; T 表示可以访问 T 的所有属性。

const proxied = <T,>(inst: Constructable<T> ) => {
    return new Proxy( inst, {
        get: function <K extends keyof T>(oTarget: Constructable<T>, sKey: K): T[K] {
            return oTarget.obj[sKey];
        },
    }) as Constructable<T> & T
}

我用来存储对象的底层类是

// stores an object internally, but allows it to be created by calling new()
class Constructable<T> {

    private _obj: T

    constructor(obj: T) {
        this._obj = obj;
    }

    // object is readonly
    get obj(): T {
        return this._obj;
    }
}

所以现在我们要创建一个基于模式的代理类。我们想要这个:

interface ProxiedConstructable<T> {
    // pass in an object T and get something which can access of the properties of T
    new( args: T ): Readonly<T>;
}

我告诉过你我并不是一路走来,这是因为代理适用于类的实例而不是类本身,所以我坚持让我们的工厂返回一些“新的”,但这是我所拥有的:

const makeProxied = <S extends { [field: string]: boolean }>(schema: S) => 
    (obj: OpaqueObject<S>) => {
        return proxied( new Constructable(obj) );
    }

像这样工作:

const test = makeProxied({
    f1: true,
    f2: false
} as const);

const testObj = test({f1: "hello world"});

const f1: string = testObj.f1;

const f2: string | undefined = testObj.f2;

Playground Link

【讨论】:

  • 感谢您的辛勤工作和详细解答!一些cmets和您的问题的答案:
  • (1) 我拥有的真正不透明对象不是将字段存储在属性中的普通 Javascript 对象。是第三方库提供的自定义数据结构。细节超出了问题的范围,因为目标是针对getField(或类似的东西)写信。
  • (2) 我将编写模式,所以你的 as const 技巧应该是可能的。
  • (3) 我已经能够动态创建 getter(这就是类工厂所做的),所以我不确定代理的东西是否必要——问题纯粹在于让 TypeScript 理解工厂生成的类的公共接口,这需要一些技巧,因为它们是在运行时生成的。
  • 看看上面@shivam-singla 的回答,它展示了如何使用Object.defineProperty 在类上定义自定义getter。 Shivam 对这个问题有更直接完整的答案,所以我会接受这个问题,但看起来他从你那里借了很多东西,而且我也从你的帖子中学到了很多东西。所以谢谢!
猜你喜欢
  • 1970-01-01
  • 2021-04-19
  • 2019-03-23
  • 1970-01-01
  • 2021-02-07
  • 1970-01-01
  • 2016-01-18
  • 1970-01-01
  • 2019-01-10
相关资源
最近更新 更多