Search code examples
typescripttypestypescript-types

Typing for progressively adding properties to an object


I have a situation that I run into fairly often: I have an object that properties are added to progressively as values are computed.

For example (based on actual code):

declare const getString: () => string;
declare const map: Map<string, unknown>

type Thing = {
  foo: string;
};

const thing: Thing = {} // Error: Property 'foo' is missing in type '{}' but required in type 'Thing'.

const string = getString() // Assume this is really expensive or can only be run once.
if (map.has(string)) thing.foo = string
else thing.foo = "blank"

There are more properties, of course.

The issue here is that the properties must be non-optional, but I can't create the object with them. I do, of course, want the object to still be typed in some way, so that invalid assignments are prevented.

How can I type this?


My first attempt was to create the object with Partial and then check it using either the function return type (if it is being returned from a function) or a variable assignment.

For example:

// Function return type
function createThing(): Thing {
  const thing: Partial<Thing> = {}
  // ...
  return thing
}

// Variable assignment
const thing: Partial<Thing> = {}
// ...
const thing2: Thing = thing

However, these checks always fail as thing is never narrowed and so is still considered to be Partial<Thing>.

Taking inspiration from "Dynamically adding properties to objects and return new type with the properties", I tried using a function to do the assignment, but without success:

const addProp = <T extends object, K extends keyof Thing, V>(
  obj: T,
  key: K,
  value: V
): T & Record<K, V> => Object.assign(obj, { [key]: value } as Record<K, V>);

thing = addProp(thing, "foo", "blank");

The addProp function returns the correct type, but the type of thing is not changed.

Of course, this would require you to reassign the variable every time, too.

In similar spirit to the above, I tried doing the assignment in a type assertion function, but those require all generic type parameters to be specified.


Note: I am aware of "Type for an object that starts empty, but then has properties added", but it is two years old; I do not know if trying to revive it would be any good. @jcalz said there that "there is no real convenient and completely type safe solution to this as far as [he knows]", but I hope that something has been added or changed that allows it, or that someone has discovered a way. I have also added my own attempts and findings, which I feel justifies posting a new question.


Solution

  • You can write a generic assertion function to add a property to an object and simultaneously narrow the apparent type of that object to match:

    function addProp<T extends object, K extends PropertyKey, V>(
        obj: T,
        key: K,
        value: V
    ): asserts obj is T & { [P in K]: V } {
        Object.assign(obj, { [key]: value });
    }
    

    And we can can see it in action below:

    const thing = {}
    // const thing: {}
    addProp(thing, "foo", string);
    // const thing: {foo: string}
    const knownToBeThing: Thing = thing; // works
    

    Before the call to addProp(), the type of thing is just {}, while afterward the apparent type is equivalent to Thing.


    Note that the call signature for addProp() is very general and allows you to add any property to any object:

    addProp(thing, "random", 123);
    // const thing: { foo: string; } & { random: number; }
    

    If instead you want something targeted toward a particular type like Thing you can change the call signature to do so:

    function addThingProp<K extends keyof Thing, P extends keyof Thing>(
        obj: { [Q in K]: Thing[Q] },
        key: P,
        value: Thing[P]
    ): asserts obj is { [Q in K | P]: Thing[Q] } {
        Object.assign(obj, { [key]: value });
    }
    

    which behaves similarly but warns on unexpected keys:

    const thing = {};
    // const thing: {}
    addThingProp(thing, "foo", "abcde");
    // const thing: {foo: string}
    const knownToBeThing: Thing = thing; // okay
    
    addThingProp(thing, "random", 123); // error!
    // ---------------> ~~~~~~~~
    

    Playground link to code