Home > Enterprise >  Can I dynamically set the key of object using typescript?
Can I dynamically set the key of object using typescript?

Time:11-30

I am looking for a way to dynamically type some values and methods on a class I have.

Example

I'll start simple. This is the behaviour I want. (I think)

const options = ["opt1", "opt2"] as const;
type AdvancedFixedValue = {
  [L in typeof options[number]]: number;
};

This lets me know if I have set a property that doesn't exist.

const advancedExample: AdvancedFixedValue = {
  opt1: 2,
  opt2: 3,
  opt3: 3 // This will error
};

The Problem

However rather than options be a defined list, the values will be semi-dynamic (they are not coming from a database or anything). Here is my full implementation.

Activity Class

On the activity class I want to add "fixed values" which is just an object, but want the keys of that object to be checked against the values of the variables on the Equation class.


type BasicFixedValue = {
  [key: string]: number;
};


class Activity {
  id: string;
  equation: Equation;
  fixedValues: BasicFixedValue;

  constructor() {
    this.id = "test";
    this.fixedValues = {};
    this.equation = new Equation({ variables: ["test"] });
  }

  public setEquation(equation: Equation): Activity {
    this.equation = equation;
    return this;
  }

  public addFixedValues(fixedValues: BasicFixedValue): Activity {
    this.fixedValues = {
      ...this.fixedValues,
      ...fixedValues
    };

    return this;
  }
}

Equation class

type EquationParams = {
  variables?: string[];
};

export class Equation {
  id: string;
  equation?: string;
  variables: string[] = [""];

  constructor(params: EquationParams) {
    this.id = "test";
    this.variables = params?.variables ?? ["hi"];
  }

  public setEquation(equation: string): Equation {
    this.equation = equation;
    return this;
  }

  public addVariable(variables: string[] | string): Equation {
    const deDupe = new Set(this.variables);

    if (variables instanceof Array) {
      variables.forEach((variable) => deDupe.add(variable));
    } else {
      deDupe.add(variables);
    }
    this.variables = Array.from(deDupe);
    return this;
  }
}

Test cases

// Make a new equation with "a" and "b"
const equation1 = new Equation({ variables: ["a", "b"] })
// Add it to the activity 
const activity1 = new Activity().setEquation(equation1);

// This should show a typescript error because "test" is not in the variables of the equation added to the activity
activity1.addFixedValues({ c: 20 });

// This should be fine
activity1.addFixedValues({ a: 20 });

// After adding "c" to the equation,
equation1.addVariable("c");

// it should now be fine
activity1.addFixedValues({ c: 20 });

CodePudding user response:

TypeScript doesn't really track type mutations very well; the default stance of the language is that an expression's type represents the possible values that expression can have, and that it does not change over time. So a variable let x: string = "foo" can never hold a number value, and if you think you might want it to, you should have annotated it like let x: string | number = "foo" instead.

Well, there is the concept of type narrowing, where the compiler will take a variable of type X and temporarily treats it as some subtype Y extends X based on control flow analysis. So let x: string | number = "foo" will cause the compiler to see x as narrowed to string, and you can write x.toUppercase() without error. If you reassign x = 4 the compiler will re-widen to string | number and then re-narrow to number so you can then write x.toFixed(2) without error:

let x: string | number = "foo";
x.toUpperCase(); // okay
x.toFixed // error
x = 5; // okay
x.toFixed(); // okay
x.toUpperCase // error

And when I first looked at your question I had some vague hope that maybe we could refactor your code so that it viewed what you were doing as this sort of scope-based type narrowing. There's even some functionality called assertion functions/methods where you annotate a void-returning class method as returning an assertion predicate of the form asserts this is Y where Y extends this, and the compiler knows that calling that function will narrow the type of the class instance.

But in practice it's very hard to use assertion methods, since they require explicit type annotations in places where you wouldn't think to use them (see microsoft/TypeScript#45385 for a feature request to lift this condition), and there's no easy way to use them to re-widen a narrowed type. And in this specific case I wasn't able to get the compiler to see the operation of calling setEquation() on an Activity as a narrowing (due to vagaries of how the compiler measures variance), so calling it as an assertion method had no effect.

The point is, this sort of thing is hard to do.


So instead of trying to do this as a mutation, we can reframe this as having the operations producing results of new types that you store in new variables if you need to refer to them multiple times. This is like using a fluent interface where you chain methods together. The rule here is that you never re-use a variable if you have performed an operation on it which would mutate its state. (You could guard against that by making everything immutable, so activity.setEquation() would return a brand new Activity instead of mutating the existing one, but I'm not going to worry about that now).

For example:

type EquationParams<K extends string> = {
    variables: K[];
};

class Equation<K extends string> {
    id: string;
    equation?: string;
    variables: K[] = [];

    constructor(params: EquationParams<K>) {
        this.id = "test";
        this.variables = params.variables;
    }

    public addVariable<L extends string>(variables: L[] | L): Equation<K | L> {
        const deDupe = new Set<K | L>(this.variables);

        if (variables instanceof Array) {
            variables.forEach((variable) => deDupe.add(variable));
        } else {
            deDupe.add(variables);
        }
        const that = this as Equation<K | L>;
        that.variables = Array.from(deDupe);
        return that;
    }
}

Here, Equation is now generic in the string literal types of the elements of its variables property. If you have an Equation<K> and call addVariable(v) where v is of type L, the compiler returns an Equation<K | L>, using a union type to represent an array of values of type K together with those of type L. Note that we need at least one type assertion in the implementation (const that = this as Equation<K | L>) to convince the compiler to start adding new L variables that are not in Equation<K>.

For completeness, here's Activity, which also needs to keep track of the variables in its equation:

class Activity<K extends string = never> {
    id: string;
    equation: Equation<K>;
    fixedValues: Partial<Record<K, number>>;

    constructor() {
        this.id = "test";
        this.fixedValues = {};
        this.equation = new Equation({ variables: [] });
    }

    public setEquation<L extends string>(equation: Equation<L>): Activity<L> {
        const that = this as Activity<any> as Activity<L>;
        that.equation = equation;
        return that;
    }

    public addFixedValues(fixedValues: Partial<Record<K, number>>): this {
        this.fixedValues = {
            ...this.fixedValues,
            ...fixedValues
        };

        return this;
    }
}

And now we can demonstrate the fluent interface, only reusing variables if the operation does not produce a value of a different type:

const equation1 = new Equation({ variables: ["a", "b"] })
const activity1 = new Activity().setEquation(equation1);
activity1.addFixedValues({ c: 20 }); // error! 
// ----------------------> ~~~~~
// Object literal may only specify known properties, and 'c' 
// does not exist in type 'Partial<Record<"a" | "b", number>>'
activity1.addFixedValues({ a: 20 });

const equation2 = equation1.addVariable("c"); // use new variable to represent new type
const activity2 = activity1.setEquation(equation2); // use new variable to represent new type
activity2.addFixedValues({ c: 20 }); // okay now

Look good!

Playground link to code

  • Related