【问题标题】:How to make required constructor options optional when they were set using MyClass.defaults(options)使用 MyClass.defaults(options) 设置所需的构造函数选项时如何使它们成为可选
【发布时间】:2021-06-30 20:42:32
【问题描述】:

假设我有一个类Base,其构造函数需要一个对象参数和至少一个version 键。 Base 类还有一个静态的 .defaults() 方法,它可以为它返回的新构造函数上的任何选项设置默认值。

在代码中,这就是我想要的

const test = new Base({
  // `version` should be typed as required for the `Base` constructor
  version: "1.2.3"
})
const MyBaseWithDefaults = Base.defaults({
  // `version` should be typed as optional for `.defaults()`
  foo: "bar"
})
const MyBaseWithVersion = Base.defaults({
  version: "1.2.3",
  foo: "bar"
})
const testWithDefaults = new MyBaseWithVersion({
  // `version` should not be required to be set at all
})

// should be both typed as string
testWithDefaults.options.version
testWithDefaults.options.foo

额外问题:如果由于version 是通过.defaults() 设置的而不需要任何键,是否可以使构造函数options 参数可选?

这是我目前的代码:

interface Options {
  version: string;
  [key: string]: unknown;
}

type Constructor<T> = new (...args: any[]) => T;

class Base<TOptions extends Options = Options> {
  static defaults<
    TDefaults extends Options,
    S extends Constructor<Base<TDefaults>>
  >(
    this: S,
    defaults: Partial<TDefaults>
  ): {
    new (...args: any[]): {
      options: TDefaults;
    };
  } & S {
    return class extends this {
      constructor(...args: any[]) {
        super(Object.assign({}, defaults, args[0] || {}));
      }
    };
  }

  constructor(options: TOptions) {
    this.options = options;
  };
  
  options: TOptions;
}

TypeScript playground

7 月 5 日更新

我应该提到级联默认值应该可以工作:Base.defaults({ one: "" }).defaults({ two: "" })

【问题讨论】:

  • 让我们同意在回答完问题后不更改问题。如果您有其他要求,请立即添加。
  • 抱歉,我不会添加任何其他要求。我试图尽可能地减少问题并省略了链接。完整上下文:我想将此功能添加到javascript-plugin-architecture-with-typescript-definitions 项目,请参阅github.com/gr2m/…
  • 这种链接在纯 JavaScript 中绝对是可能的,至少有两种我能想到的方式。然而,在 TypeScript 中,以您想要的方式实现这一点要复杂得多。
  • 我知道这在 JS 中是可能的,我已经做了很多年了。但是你能想出什么方法让它也适用于 Types 吗?
  • 哦,太好了。您能否分享您用于合并默认值的纯 JavaScript 解决方案,我会尝试在那里添加类型?如果它太大而无法解决问题,让我们使用一个新的或现有的 TypeScript 游乐场。

标签: typescript typescript-generics


【解决方案1】:

让我们逐一了解这些需求,并就如何实现它们制定一个粗略的计划。

  1. 可以设置默认值的静态.defaults()方法

    这里的想法是在原始构造函数(即子构造函数)“之上”创建一个结构。该结构将采用默认值和其余值,将它们组合成一个对象,并将其提供给原始构造函数。实际上,您在这方面非常接近。此设置的类型肯定会包括泛型,但如果您熟悉泛型,这应该不是问题。

  2. 如果不需要任何键,则将构造函数 options 参数设为可选

    这部分问题实际上比您想象的要棘手。要实现它,我们必须使用:

    • keyof 运算符在应用于没有属性的对象 ({}) 时会生成 never(“空对象从不具有属性,因此也没有键”);
    • TypeScript 能够以元组的形式在函数参数列表中移动,这将有助于根据给定条件(在我们的例子中,它是“对象为空”)为构造函数分配不同的参数;李>
  3. 级联默认值:Base.defaults({ one: "" }).defaults({ two: "" })

    因为.defaults() 的结果是一个子类(见上文),它必须具有其父类的所有静态成员,包括.defaults() 本身。所以,从纯 JavaScript 的角度来看,没有什么新东西要实现,它应该已经可以工作了。

    然而,在 TypeScript 中,我们遇到了一个大问题。 .defaults() 方法必须能够访问当前类的默认值,以便为新的默认值生成类型,并结合新旧对象。例如,给定标题中的情况,为了得到{ one: string } &amp; { two: string },我们直接从参数推断{ two: string }(新默认值),并从其他地方推断{ one: string }(旧默认值)。最好的地方是类的类型参数(例如,class Base&lt;Defaults extends Options&gt;),但这里是交易:static members cannot reference class type parameters

    a workaround for this,但是,它需要一些半合理的假设和一点点放弃 DRY。最重要的是,您不能再以声明方式定义类,您必须强制(也称为“动态”)创建继承链的第一个“最顶层”成员(如const Class = createClass();),我个人认为相当不幸(尽管它工作得很好)。

话虽如此,结果如下(还有一个playground;请随意折叠/删除&lt;TRIAGE&gt;部分):

type WithOptional<
    OriginalObject extends object,
    OptionalKey extends keyof OriginalObject = never,
> = Omit<OriginalObject, OptionalKey> & Partial<Pick<OriginalObject, OptionalKey>>;

type KeyOfByValue<Obj extends object, Value> = {
    [Key in keyof Obj]: Obj[Key] extends Value ? Key : never;
}[keyof Obj];

type RequiredKey<Obj extends object> =
    Exclude<KeyOfByValue<Obj, Exclude<Obj[keyof Obj], undefined>>, undefined>;

type OptionalParamIfEmpty<Obj extends object> =
    RequiredKey<Obj> extends never ? [ Obj? ] : [ Obj ];

function createClass<
    Options extends object,
    OptionalKey extends keyof Options = never,
>(
    defaults?: Pick<Options, OptionalKey>,
    Parent: new(options: Options) => object = Object
) {
    return class Class extends Parent {
        static defaults<
            OptionalKey2 extends keyof Options,
        >(
            additionalDefaults: Pick<Options, OptionalKey2>,
        ) {
            const newDefaults = { ...defaults, ...additionalDefaults } as Options;

            return createClass<Options, OptionalKey | OptionalKey2>(newDefaults, this);
        }

        public options: Options;

        constructor(
            ...[explicit]: OptionalParamIfEmpty<WithOptional<Options, OptionalKey>>
        ) {
            const options = { ...defaults, ...explicit } as Options;

            super(options);

            this.options = options;
        }
    }
}

分解:

  • createClass() 应该被显式调用仅用于创建继承链中的第一个类(后续子类通过 .defaults() 调用创建)。

  • createClass() 接受(全部 - 可选):

    • options 属性的类型定义;
    • 要预填充的options 的摘录(defaults 对象,函数的第一个值参数);
    • 对父类的引用(第二个值参数),默认设置为Object(所有对象的公共父类)。
  • createClass() 中的 Options 类型参数应该明确提供。

  • createClass() 中的 OptionalKey 类型参数是从提供的 defaults 对象的类型自动推断出来的。

  • createClass() 返回一个为constructor() 更新类型的类,即defaults 中已经存在的属性在explicit 中不再需要。

  • 如果所有options 属性都是可选的,则explicit 参数本身将变为可选。

  • 由于返回类的整个定义都放在一个函数中,它的.defaults() 方法可以通过闭包直接访问上面的defaults 对象。这允许该方法只需要额外的默认值;然后将两组默认值合并到一个对象中,并连同当前类的定义一起传递给 createClass(defaults, Parent) 以创建具有预填充默认值的新子类。

  • 由于返回的类需要在构造函数的某处调用super(),为了保持一致性,父类的构造函数强制将options: Options 作为其第一个参数。然而,构造函数完全有可能忽略这个参数。这就是为什么在super() 调用之后,options 属性的值无论如何都会被显式设置。

【讨论】:

  • 谢谢你的回答,我现在正在看!一个问题:为什么要添加interface BaseParams extends BaseProps { } 而不是只使用BaseProps
  • 也只是好奇:// &lt;TRIAGE... cmets 对 TypeScript 或 Playground 有什么特殊意义吗?
  • 您能解释一下是否/为什么需要implements BaseProps 部分吗?
  • 实现不需要。有没有告诉编译器Base类的属性与构造函数中BaseParams对象的属性有关。只是好的编程,你知道的。
  • 不,// &lt;TRIAGE&gt;// &lt;/TRIAGE&gt; 只是任意的 cmets
【解决方案2】:
interface Options {
  version: string;
  [key: string]: unknown;
}

type CalssType = abstract new (...args: any) => any;

// obtain parameters of constructor
type Params = ConstructorParameters<typeof Base>

// obtain instance type
type Instance = InstanceType<typeof Base>

// make first element of tuple Partial
type FstPartial<Tuple extends any[]> = Tuple extends [infer Fst, ...infer Tail] ? [Partial<Fst>, ...Tail] : never

// clone constructor type and replace first argument in rest parameters with partial
type GetConstructor<T extends CalssType> = new (...args: FstPartial<ConstructorParameters<T>>) => InstanceType<T>

type Constructor<T> = new (...args: any[]) => T;

class Base<TOptions extends Options = Options> {
  static defaults<
    TDefaults extends Options,
    S extends Constructor<Base<TDefaults>>
  >(
    this: S,
    defaults: Partial<TDefaults>
  ): {
    new(...args: any[]): {
      options: TDefaults;
    };
  } & GetConstructor<typeof Base> { // <--- change is here
    return class extends this {
      constructor(...args: any[]) {
        super(Object.assign({}, defaults, args[0] || {}));
      }
    };
  }

  constructor(options: TOptions) {
    this.options = options;
  };

  options: TOptions;
}

const test = new Base({
  // `version` should be typed as required for the `Base` constructor
  version: "1.2.3"
})
const MyBaseWithDefaults = Base.defaults({
  // `version` should be typed as optional for `.defaults()`
  foo: "bar"
})
const MyBaseWithVersion = Base.defaults({
  version: "1.2.3",
  foo: "bar"
})
const testWithDefaults = new MyBaseWithVersion({}) // ok

// should be both typed as string
testWithDefaults.options.version
testWithDefaults.options.foo

Playground

【讨论】:

  • 当我将const testWithDefaults = new MyBaseWithVersion({}) 更改为const testWithDefaults = new MyBaseWithVersion({ someOption: "value" }) 时,我希望testWithDefaults.options.someOption 被输入为string,但它根本没有输入。您可以在实现中轻松添加这些内容吗?
  • 链接.defaults() 调用也不起作用:Base.defaults({ one: "" }).defaults({ two: "" }) 不起作用
【解决方案3】:

Josh Goldberg 的帮助下,我们得出结论,无法使用当今的 TypeScript 键入无限可链接的 Base.defaults().defaults()... API。

我们采取的做法是实现最多 3 个 .defaults() 调用的链接,这仍将在实例上正确设置 .options 属性。

这是我们最终实现的完整类型声明文件。它有点复杂,因为它还包括 .plugin()/.plugins API,为了简单起见,我在原始问题中省略了该 API。

export declare namespace Base {
  interface Options {
    version: string;
    [key: string]: unknown;
  }
}

declare type ApiExtension = {
  [key: string]: unknown;
};
declare type Plugin = (
  instance: Base,
  options: Base.Options
) => ApiExtension | void;

declare type Constructor<T> = new (...args: any[]) => T;
/**
 * @author https://stackoverflow.com/users/2887218/jcalz
 * @see https://stackoverflow.com/a/50375286/10325032
 */
declare type UnionToIntersection<Union> = (
  Union extends any ? (argument: Union) => void : never
) extends (argument: infer Intersection) => void
  ? Intersection
  : never;
declare type AnyFunction = (...args: any) => any;
declare type ReturnTypeOf<T extends AnyFunction | AnyFunction[]> =
  T extends AnyFunction
    ? ReturnType<T>
    : T extends AnyFunction[]
    ? UnionToIntersection<Exclude<ReturnType<T[number]>, void>>
    : never;

type ClassWithPlugins = Constructor<any> & {
  plugins: any[];
};

type ConstructorRequiringVersion<Class extends ClassWithPlugins, PredefinedOptions> = {
  defaultOptions: PredefinedOptions;
} & (PredefinedOptions extends { version: string }
  ? {
      new <NowProvided>(options?: NowProvided): Class & {
        options: NowProvided & PredefinedOptions;
      };
    }
  : {
      new <NowProvided>(options: Base.Options & NowProvided): Class & {
        options: NowProvided & PredefinedOptions;
      };
    });

export declare class Base<TOptions extends Base.Options = Base.Options> {
  static plugins: Plugin[];

  /**
   * Pass one or multiple plugin functions to extend the `Base` class.
   * The instance of the new class will be extended with any keys returned by the passed plugins.
   * Pass one argument per plugin function.
   *
   * ```js
   * export function helloWorld() {
   *   return {
   *     helloWorld () {
   *       console.log('Hello world!');
   *     }
   *   };
   * }
   *
   * const MyBase = Base.plugin(helloWorld);
   * const base = new MyBase();
   * base.helloWorld(); // `base.helloWorld` is typed as function
   * ```
   */
  static plugin<
    Class extends ClassWithPlugins,
    Plugins extends [Plugin, ...Plugin[]],
  >(
    this: Class,
    ...plugins: Plugins,
  ): Class & {
    plugins: [...Class['plugins'], ...Plugins];
  } & Constructor<UnionToIntersection<ReturnTypeOf<Plugins>>>;

  /**
   * Set defaults for the constructor
   *
   * ```js
   * const MyBase = Base.defaults({ version: '1.0.0', otherDefault: 'value' });
   * const base = new MyBase({ option: 'value' }); // `version` option is not required
   * base.options // typed as `{ version: string, otherDefault: string, option: string }`
   * ```
   * @remarks
   * Ideally, we would want to make this infinitely recursive: allowing any number of
   * .defaults({ ... }).defaults({ ... }).defaults({ ... }).defaults({ ... })...
   * However, we don't see a clean way in today's TypeScript syntax to do so.
   * We instead artificially limit accurate type inference to just three levels,
   * since real users are not likely to go past that.
   * @see https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/57
   */
  static defaults<
    PredefinedOptionsOne,
    ClassOne extends Constructor<Base<Base.Options & PredefinedOptionsOne>> & ClassWithPlugins
  >(
    this: ClassOne,
    defaults: PredefinedOptionsOne
  ): ConstructorRequiringVersion<ClassOne, PredefinedOptionsOne> & {
    defaults<ClassTwo, PredefinedOptionsTwo>(
      this: ClassTwo,
      defaults: PredefinedOptionsTwo
    ): ConstructorRequiringVersion<
      ClassOne & ClassTwo,
      PredefinedOptionsOne & PredefinedOptionsTwo
    > & {
      defaults<ClassThree, PredefinedOptionsThree>(
        this: ClassThree,
        defaults: PredefinedOptionsThree
      ): ConstructorRequiringVersion<
        ClassOne & ClassTwo & ClassThree,
        PredefinedOptionsOne & PredefinedOptionsTwo & PredefinedOptionsThree
      > & ClassOne & ClassTwo & ClassThree;
    } & ClassOne & ClassTwo;
  } & ClassOne;

  static defaultOptions: {};

  /**
   * options passed to the constructor as constructor defaults
   */
  options: TOptions;

  constructor(options: TOptions);
}
export {};

这是将该功能添加到 javascript-plugin-architecture-with-typescript-definitions 模块的拉取请求。

https://github.com/gr2m/javascript-plugin-architecture-with-typescript-definitions/pull/59

【讨论】:

  • 不完全确定为什么我的答案不能无限链接?
猜你喜欢
  • 2022-12-13
  • 1970-01-01
  • 2021-07-03
  • 1970-01-01
  • 2018-05-20
  • 2020-11-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多