Dragan Okanovic
Dragan Okanovic

Reputation: 7781

Typescript - extending object's type progressively

I am trying to achieve the following with TS:

let m: Extendable
m.add('one', 1)
// m now has '.one' field
m.add('two', 2)
// 'm' now has '.one' and '.two' fields

I'm familiar with returning extended types in TS via:

function extend<T, V>(obj: T, val: V): T & {extra: V} {
    return {
        ...obj,
        extra: val
    }
}

Now, there are two issues in my case:

1) object m needs to update its type after the add() has been called to reflect addition of a new field

2) new field's name is parameterized (ain't always extra e.g.)

First issue might be resolved by using class definition and somehow using TypeThis utility to re-adjust the type, but I wasn't able to find enough documentation about how to use it.

Any help or guidance is welcome. Thanks!

Upvotes: 6

Views: 1726

Answers (1)

jcalz
jcalz

Reputation: 329418

TypeScript 3.7 introduced assertion functions which can be used to narrow the type of passed-in arguments or even this. Assertion functions look kind of like user-defined type guards, but you add an asserts modifier before the type predicate. Here's how you could implement Extendable as a class with add() as an assertion method:

class Extendable {
    add<K extends PropertyKey, V>(key: K, val: V): asserts this is Record<K, V> {
        (this as unknown as Record<K, V>)[key] = val;
    }
}

When you call m.add(key, val) the compiler asserts that m will have a property with key with the type of key and a corresponding value with the type of val. Here's how you'd use it:

const m: Extendable = new Extendable();
//     ~~~~~~~~~~~~ <-- important annotation here!
m.add('one', 1)
m.add('two', 2)

console.log(m.one.toFixed(2)); // 1.00
console.log(m.two.toExponential(2)); // 2.00e+0

That all works as you expect. After you call m.add('one', 1), you can refer to m.one with no compiler warning.

Unfortunately there's a fairly major caveat; assertion functions only work if they have an explicitly annotated type. According to the relevant pull request, "this particular rule exists so that control flow analysis of potential assertion calls doesn't circularly trigger further analysis."

That means the following is an error:

const oops = new Extendable(); // no annotation
  oops.add("a", 123); // error!
//~~~~~~~~ <-- Assertions require every name in the call target to be declared with
// an explicit type annotation.

The only difference is that the type of oops is inferred to be Extendable instead of annotated as Extendable as m is. And you get an error calling oops.add(). Depending on your use case this could either be no big deal or a showstopper.


Okay, hope that helps; good luck!

Link to code

Upvotes: 5

Related Questions