I have a JS library that I'm trying to create types for, imagine the following class:
class Table {
register(...fields) {
fields.forEach(field => {
Object.defineProperty(this, field, {
get: () => {
console.log(`Accessed ${field}`);
return 1;
},
})
})
}
}
and then you could use Table
this way:
let table = new Table();
table.register(["uuid", "name", "url"]);
table.name; // has a value of 1 and prints "Accessed name"
Writing a declaration for the class seems easy
class Table {
register(...fields: string[]): void;
}
However, I don't know whether it's possible for TypeScript to extend the instance's type after calling table.register()
.
let table = new Table();
table.register(["uuid", "name", "url"]);
table.name; // TypeScript error, `name` is not a field of Table
I know it's possible to change the JS code to return the instance itself and then TypeScript could simply return a different type, but in the case the function doesn't return anything, is it possible to tell TypeScript that the instance's type has slightly changed after the call?
You can turn
void
-returning functions and methods into assertion functions by annotating their return type with anasserts
predicate. This allows the compiler to interpret a call to that function as a narrowing of the apparent type of one of the arguments (orthis
in the case of an assertion method).Adding properties to an existing type is considered a narrowing, according to TypeScript's structural type system. That's because object types are open/extensible and not sealed/exact; given types
interface Foo {x: string}
andinterface Bar extends Foo {y: string}
, you can say that everyBar
is aFoo
but not vice versa, and therefore thatBar
is narrower thanFoo
. (Note that there is a longstanding open request to support exact types at microsoft/TypeScript#12936, and people often are confused into thinking that TypeScript types are exact because of excess property warnings on object literals; I won't digress further here, but the main point is that extra properties constitute a narrowing and not an incompatibility).So, because
register()
is avoid
-returning method which serves to add properties tothis
, you can get the behavior you're looking for by makingregister()
into an assertion method.Here's one way to do it:
The return type
asserts this is this & Record<P, 1>
implies that a call totable.register(...fields)
will cause the type oftable
(the value calledthis
inasserts this
) to be narrowed from whatever it starts as (the type calledthis
inthis & Record<P, 1>
) to the intersection of its starting type withRecord<P, 1>
(using theRecord<K, V>
utility type), whereP
is the union of string literal types of the values passed in asfields
.That means that by starting with
table
of typeTable
, a call totable.register("x", "y", "z")
should narrowtable
toTable & Record<"x" | "y" | "z", 1>
, which is equivalent toTable & {x: 1, y: 1, z: 1}
.Let's see it in action, before exploring some important caveats:
That's more or less what you wanted, I think. The compiler allows you to access
table.name
after the call thetable.register("uuid", "name", "url")
. I also add a"hello"
field, and you can see that the compiler is unhappy about accessing it before it is created, but happy afterward.So, hooray. Now for the caveats:
The first one is shown above; you are required to annotate the
table
variable with theTable
type in order for its assertion method to behave. If you don't, you get this awful thing:This is a basic limitation with assertion functions and assertion methods. According to microsoft/TypeScript#32695, the PR that implemented assertion functions (emphasis mine):
And microsoft/TypeScript#33622 is the PR that implemented the particular error message above (before this it would just silently fail to narrow). It's not great, and it bites people every so often.
It would be great if it could be fixed, but according to this comment on the related issue microsoft/TypeScript#34596:
So I guess if you want this to work, you're going to need an explicit type annotation.
Also, a general problem with control flow analysis is that its effects do not cross function boundaries. You can read all about it at microsoft/TypeScript#9998. This implies that closures over narrowed values will lose their narrowing, perhaps surprisingly:
Inside
doSomething()
, the type oftable
has been widened back toTable
. The standard workaround for such cases is to "save" the narrowed state by assigning the narrowed value to a newconst
variable, and then use theconst
inside the function:This works because
_table
is assigned fromtable
, whose apparent type at the time of the assignment contains ahello
property. And therefore_table
will always and forever have such a property, even inside closures.And so, that's great, but... well, it's not very much different from having the
register()
function return a new value of the narrowed type and having you use the new value instead of the old one, which is the approach you mentioned in the question.So depending on how you use
Table
instances, assertion methods might be more trouble than they're worth. Still, they do exist, and are exactly what you were asking about in your question, and they do kind of sort of work sometimes, so you should keep them in mind!Playground link to code