6

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!

Dragan Okanovic
  • 7,509
  • 3
  • 32
  • 48
  • It's a fair assumption that first param to `add()` is a literal, not a variable. – Dragan Okanovic Dec 06 '19 at 16:39
  • @T.J.Crowder the returning instance is kinda easy. I think the problem is how reflect mutation without the return statement. – Maciej Sikora Dec 06 '19 at 17:04
  • 2
    Some modification of your playground - https://www.typescriptlang.org/play/index.html?ssl=12&ssc=33&pln=12&pc=16#code/FAYwNghgzlAECiAPALgUwHYBMICMytgG9hZYJNMAeAaVlRQ0zgAUAnAewAdVXkBPaqj4AaWADUAfAAp0EALaoAXLGqiAbhDABXJeICUy5AAsAlnABkRANqC+sE+hUBdZWIC+REqVj5ksRLAAvLDGZmRwEOh8ANxepIhWsgpOQbAa2qix3rCsqMharI6IWbBuwGW+sHLKSGhYuPip6KgA7ggM9XioUnqxlXIATKlyAHTkmFIA5OzNk6IAjL2gM1Ds+CNg7ADmUoMjM6hL-QDMwwNjFFPILexzsIuxICtrqBvbu8cj1+y9QA – Maciej Sikora Dec 06 '19 at 17:09
  • @MaciejSikora - I knew there was a better way to do the small bit I did. :-) – T.J. Crowder Dec 06 '19 at 17:14
  • Nice @MaciejSikora. I'm trying to figure out answer from this https://stackoverflow.com/questions/50899400/how-can-we-type-a-class-factory-that-generates-a-class-given-an-object-literal as it updates type, but there is so much other code that it becomes very hard to see what is important. – Dragan Okanovic Dec 06 '19 at 17:14
  • Nope this answer has nothing to this problem. There is switch in the constructor, so we do the assignment. The returned type will not change after. In your question type is changing every method call. The standard approach works by method chaining obj.add().add().add() and this nicely inference the return. But without using return looks like not possible, we can play with type guards but it will need additional condition. Will try to make some of this, but later today – Maciej Sikora Dec 07 '19 at 08:52
  • 1
    There’s also [assertion functions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) in TS3.7+ which could solve this, but there’s an annoying caveat involving type annotations (`let e = new Extendable()` won’t work but `let e: Extendable = new Extendable()` will) so I’m not sure if it’s worth it. – jcalz Dec 07 '19 at 11:26
  • @AbstractAlgorithm are you still working with WebGL? – gandra404 May 29 '20 at 08:41
  • @gandra404 yes, when needed, but not on a daily basis. why even ask? – Dragan Okanovic Jun 04 '20 at 11:54

1 Answers1

5

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is some kind of magic. But it indeed achieves what I was after. I do not understand why it doesn't assert for `Record & {[key]: V}` or something like that, i.e. when does type get extended with an additional field? I can see it being assigned later, but when does the type change and how/why? Also, the caveat doesn't bother me for my use case. – Dragan Okanovic Dec 08 '19 at 01:40
  • I'm not sure I understand the question. The return type of `add()` as `this is Record` means "if `this.add()` returns, then the type of `this` will be narrowed to `Record`". So `m` starts as `Extendable`, and you call `m.add("one",1)`, anything after that line will have `m` as `Extendable & Record<"one", number>`. And after you then call `m.add("two", 2)`, anything after that line will have `m` as `Extendable & Record<"one",number> & Record<"two", number>`. The implementation of `add()` has to actually add the field or the assertion will be a lie. – jcalz Dec 08 '19 at 01:50
  • I understand what you wrote and what it's the logic, except the place where the concatenation of the two types happens actually (`Extendable & {...}`, or `Extendable & {...} & {...}`. Assert only casts/ensures it's `Record`, but not `whatever is previous & Record`. Or does maybe saying `this is Record` does that? – Dragan Okanovic Dec 08 '19 at 02:02
  • 1
    yeah, I think the intersection just happens automatically in some cases. If it bothers you you can make it `this is this & Record` instead so it'll be explicit. – jcalz Dec 08 '19 at 02:04
  • Yeah, got it. I think that `asserts this is Record` shouldn't be read as "`this` is of type `Record`", but "`this` satisfies `Record` for some part of `this`". Similar to how type guards generally do it - they don't match exactly, but ensure what kind of interface is available. – Dragan Okanovic Dec 08 '19 at 02:07
  • @AbstractAlgorithm - Yeah, "is" doesn't have to mean "is only." If I have `Foo` and `Bar extends Foo` and `const b: Bar`, `b` is `Foo`. It's not *only* `Foo`, but it's `Foo`. – T.J. Crowder Dec 08 '19 at 12:41