Tips for converting necessary constructor choices into discretionary ones after they have been designated by the MyClass.defaults(options) method

If I create a class called Base with a constructor that needs one object argument containing at least a version key, the Base class should also include a static method called .defaults() which can set defaults for any options on the new constructor it returns.

Here is what I am trying to achieve in code:

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

Bonus question: Can I make the constructor's options argument optional if none of the keys are required due to version being set via .defaults()?

This is the code I have written so far:

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

Update Jul 5

I forgot to mention that cascading defaults should work:

Base.defaults({ one: "" }).defaults({ two: "" })

Answer №1

Let's review the requirements and outline a plan for implementing them.

  1. Create a static .defaults() method to set defaults

    The approach here would involve constructing a structure "above" the original constructor, essentially a child constructor. This structure would take default values along with the remaining values, merge them into a single object, and provide it to the original constructor. You were quite close to this concept. The implementation of this setup will undoubtedly include generics, but if you are familiar with generics, tackling this should not pose a challenge.

  2. Optionalize the constructor options argument when none of the keys are mandatory

    This aspect of the requirement is more intricate than it appears at first glance. To address this, we'll need to leverage:

    • the behavior of the keyof operator, which yields never when applied to an object without properties ({}) ("empty objects do not possess properties or keys");
    • TypeScript's capability to rearrange function parameter lists in tuple form, enabling us to assign different parameters to the constructor based on a specific condition (in our case, when the object is empty);
  3. Implement cascading defaults:

    Base.defaults({ one: "" }).defaults({ two: "" })

    Since the output of .defaults() is a child class (refer back to the above discussion), it must inherit all static members from its parent class—this includes .defaults() itself. Therefore, from a pure JavaScript standpoint, there shouldn't be any new implementations required; it should already work as expected.

    In TypeScript, however, we encounter a significant obstacle. The .defaults() method requires access to the existing class's defaults to determine types for the combined new defaults derived from both old and new objects. For instance, in the scenario mentioned, to derive

    { one: string } & { two: string }
    , we deduce { two: string } (new defaults) directly from the given argument, while obtaining { one: string } (old defaults) from elsewhere. The most suitable location would be the class's type arguments (e.g.,
    class Base<Defaults extends Options>
    ). However, the issue arises because static members cannot reference class type parameters.

    A workaround exists, albeit requiring certain reasonable assumptions and some compromise on DRY principles. Most notably, declaring the class imperatively instead of declaratively becomes necessary, forcing you to dynamically create the initial, top-level member of the inheritance chain (such as const Class = createClass();), which may seem rather unfortunate despite being functional.

Considering all this, here is the resulting solution (along with a playground; feel free to collapse/remove the <TRIAGE> section):

(code snippet provided)

Breakdown:

  • createClass() should only be invoked explicitly to create the initial class in the inheritance chain (subsequent child classes are generated through .defaults() calls).

  • createClass() accepts (all optionally):

    • a type definition for the options property;
    • a portion of options for pre-population (the defaults object, the initial value argument of the function);
    • a reference to the parent class (the second value argument), defaulted to Object (a common parent class for all objects).
  • The Options type argument in createClass() needs to be explicitly provided.

  • The OptionalKey type argument in createClass() is inferred automatically from the type of the supplied defaults object.

  • createClass() returns a class featuring updated typings for constructor(), wherein properties already present in defaults are no longer obligatory in explicit.

  • If all properties of options are optional, the explicit argument itself becomes discretionary.

  • Since the entire class definition is enclosed within a function, the .defaults() method can access the aforementioned defaults object via closure. This allows the method to solely necessitate additional defaults; these two sets of defaults are then merged into a single object and, together with the current class definition, passed to createClass(defaults, Parent) to create a new child class replete with pre-filled defaults.

  • To maintain consistency, the returned class must invoke super() somewhere in the constructor, warranting the parent class's constructor to accept options: Options as its primary argument. Nonetheless, a constructor has the ability to disregard this argument; thus, post super() invocation, the options property value is manually established regardless.

Answer №2

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

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

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

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

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

// duplicate 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

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

Playground

Answer №3

Collaborating with Josh Goldberg, we reached the consensus that creating an infinite chainable API like Base.defaults().defaults()... in today's TypeScript is not feasible.

As an alternative, we decided to introduce a chaining mechanism allowing up to 3 calls of .defaults(), ensuring the correct setting of the .options property on the instance.

Below is the detailed type declaration file that was eventually implemented. It may seem intricate due to the inclusion of the .plugin()/.plugins API, which I omitted from my initial query for simplicity.

Your rewritten content goes here...

You can find the pull request incorporating this functionality into the

javascript-plugin-architecture-with-typescript-definitions
module at:

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

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

The Azure function encounters an AuthorizationFailure error while attempting to retrieve a non-public file from Azure Blob Storage

Within my Azure function, I am attempting to retrieve a file from Blob Storage labeled myappbackendfiles. The initial code (utils/Azure/blobServiceClient.ts) that initializes the BlobServiceClient: import { BlobServiceClient } from "@azure/storage-bl ...

What steps should I take to troubleshoot this Angular issue within a Visual Studio 2022 project that utilizes the Standalone Angular template?

After going through this tutorial and meticulously following each step, I encountered an error when trying to run the application: https://i.sstatic.net/EvYgg.jpg Can anyone advise on how to resolve this issue? I'm facing a similar error while attem ...

ESLint is indicating an error when attempting to import the screen from @testing-library/react

After importing the screen function from @testing-library/react, I encountered an ESLint error: ESLint: screen not found in '@testing-library/react'(import/named) // render is imported properly import { render, screen } from '@testing-lib ...

Encountering an error while trying to import JSON in TypeScript

I am in need of using mock JSON data to test the rendering of my front-end. import MOCK_FAQ from '../../mocks/FAQ.json'; However, when attempting to import the file, I encountered this exception: Cannot find module '../../mocks/FAQ.json&a ...

Exploration of narrowing union types in React with TypeScript

import { Chip } from "@mui/material"; type CourseFilterChipsRangeType = { labels: { from: string; to: string }; values: { from: number; to: number }; toggler: (from: number, to: number) => void; }; type CourseFilterChipsCheckType = { ...

Why don't my absolute paths work on nested folders in React and Typescript?

Struggling to configure absolute paths in my React project, encountering challenges with nested folders and the use of @ prefix. Here's what I have set up in my tsconfig.json: { "compilerOptions":{ "baseUrl":"src", ...

Transfer your focus to the following control by pressing the Enter key

I came across a project built on Angular 1.x that allows users to move focus to the next control by pressing the Enter key. 'use strict'; app.directive('setTabEnter', function () { var includeTags = ['INPUT', 'SELEC ...

Guide for adding an OnClick event to a MatTable row:

I am looking to add functionality for clicking on a specific row to view details of that user. For instance, when I click on the row for "user1", I want to be able to see all the information related to "user1". Here is the HTML code snippet: <table ma ...

Is it possible for the ionic ionViewDidEnter to differentiate between pop and setRoot operations?

I am facing an issue with my ionic 3 page where I need to refresh the data on the page only if it is entered via a navCtrl.setRoot() and not when returned to from a navCtrl.pop(). I have been using ionViewDidEnter() to identify when the page is entered, bu ...

Preventing Firebase duplicates leads to the error of not being able to read the property 'apps'

Struggling to incorporate Firebase into a TypeScript/NextJS project, I have encountered difficulties. Despite successfully importing and initializing the app: import * as firebase from "firebase/app"; import { collection, getDocs } from "fir ...

React validation functionalities

Incorporating React, I am attempting to implement a validation feature within a footer containing multiple buttons with unique values such as home, orders, payments and more. My goal is to dynamically display an active state for the button corresponding to ...

Angular allows for creating a single build that caters to the unique global style needs of every

Currently, I am working on a project for two different clients, each requiring a unique style.css (Global CSS). My goal is to create a single production build that can be served to both clients, who have different domains. I would like the global style t ...

Vue: Storing selected list values in an array

I am working on a Vue application where I need to select two elements from a list component and place them inside an array. Currently, I have my list set up with selection functionality thanks to Vuetify. I have bound the selected items to an array using v ...

Ensuring uniqueness in an array using Typescript: allowing only one instance of a value

Is there a simple method to restrict an array to only contain one true value? For instance, if I have the following types: array: { value: boolean; label: string; }[]; I want to make sure that within this array, only one value can be set to t ...

Exploring Angular Component Communication: Deciphering between @Input, @Output, and SharedService. How to Choose?

https://i.stack.imgur.com/9b3zf.pngScenario: When a node on the tree is clicked, the data contained in that node is displayed on the right. In my situation, the node represents a folder and the data consists of the devices within that folder. The node com ...

Display a loading GIF for every HTTP request made in Angular 4

I am a beginner with Angular and I am looking for a way to display a spinner every time an HTTP request is made. My application consists of multiple components: <component-one></component-one> <component-two></component-two> <c ...

Looking to create universal React component wrappers?

I am working with a set of functional components that share a common set of properties, for example: const A = ({ x, y, z }) = {...} const B = ({ x, y, z }) = {...} For these components, I have predefined configurations: const styles { A: { ty ...

Transform the JSON object into a TypeScript array

Currently working on a project that requires converting a JSON object into a TypeScript array. The structure of the JSON is as follows: { "uiMessages" : { "ui.downtime.search.title" : "Search Message", "ui.user.editroles.sodviolation.entries" : ...

Sundays and last days are excluding React-big-calendar and dayjs longer events from being displayed

I've encountered a bug in my calendar view implementation. Long events are not displaying on Sundays or the ending day. Please refer to this image for reference: https://i.stack.imgur.com/V0iis.png Event details: Start time: Mon Aug 07 2023 15:44:00 ...

Steps to include a Target property in a freshly created MouseEvent

Trying to dispatch a contextMenu event, I've noticed that in the MouseEvent interface for TypeScript, the target property is missing, even though it is documented in the contextMenu documentation. Here's my TypeScript snippet: const emulatedMou ...