function dostuff(tag: any) {
if (tag in primitives) return primitives[tag]
// ...
If the condition is true, tag
still has the type any
.
Is there a way to make this work according to in operator narrowing?
Currently, in
operator narrowing only narrows the type of the second operand. So if you check key in obj
, the type of obj
might be narrowed, but key
will be unaffected. People generally use in
operator narrowing to filter a union-typed object.
There is an open feature request at microsoft/TypeScript#43284 to allow in
to also narrow the first operand, so that key in obj
will cause key
to be assignable to keyof typeof obj
. It's unlikely that this will ever be implemented, seeing as a virtually identical feature request at microsoft/TypeScript#48149 was declined.
The main problem with narrowing key
to be keyof typeof obj
is that TypeScript object types are not sealed or "exact" (to use terminology from Flow). If obj
is of type {a: string}
, that means it has a string
-valued a
property, but it does not mean that it has only that property. It might well have a lot of other properties that TypeScript doesn't know about. TypeScript object types are open, extendible, or "inexact". That allows you to extend interfaces and add properties while maintaining compatibility with the extended type. There's a longstanding open feature request at microsoft/TypeScript#12936 to support exact types. If that were ever implemented, then if obj
were of an exact type, key in obj
really would be enough to narrow key
. But as it stands now, if obj
has more properties than TS knows about, then key in obj
doesn't really give you any reason to believe that key
happens to be in the set of keys TypeScript does know about. Here's an example of the kind of problem you run into:
interface Foo {
x: string,
y: string,
}
function process(obj: Foo, key: string) {
if (key in obj) {
// imagine that key is narrowed to
console.log(obj[key].toUpperCase())
}
}
That looks like it might be fine, right? The key in obj
check narrows key
from string
to keyof Foo
, so that obj[key]
is known to be string
, and thus has a toUpperCase()
method. But then:
interface Bar extends Foo {
a: boolean,
b: number,
c: Date
}
const bar: Bar = { x: "abc", y: "def", a: true, b: 123, c: new Date() };
process(bar, "b"); // <-- RUNTIME ERROR!
// obj[key].toUpperCase is not a function
TypeScript will allow you to call process(bar, "b")
, because bar
is a Bar
, which is a Foo
, because TypeScript types are open and not sealed. And then we see the problem inside process
. If TypeScript assumes that "b" in bar
implies that "b"
is actually "x"
or "y"
, that's a problem, and you get a runtime error, because obj.b
is not a string
, so it has no toUpperCase()
method. It's therefore not sound or type safe to allow such narrowing.
So that's the stated reason why they're unlikely to implement it. Of course the current in
-operator narrowing is also unsafe in a similar way, for the same reason:
function processTwo(obj: { a: string } | { b: string }) {
if ("a" in obj) {
console.log(obj.a.toUpperCase())
} else {
console.log(obj.b.toUpperCase())
}
}
const obj = { a: 123, b: "abc" };
processTwo(obj); // <-- RUNTIME ERROR!
// obj.a.toUpperCase is not a function
So the soundness issue is already present, and they allow it because this kind of thing doesn't happen very often. When people use in
operator narrowing, they assume types are exact.
In my mind the only problem with supporting key narrowing is determining how it should interact with object narrowing. If you check key in obj
it certainly shouldn't narrow both key
and obj
, right? See this comment on microsoft/TypeScript#42384. Perhaps there's some heuristic, like... if key
is wide like string
, or a union, then we narrow key
, but if key
is some known string literal like "a"
then we narrow obj
. It's not obvious how to do this that wouldn't break existing code, though.
Anyway, it's unlikely to happen. So if you need this functionality, you'll have to implement it yourself. You can do so by wrapping the in
check in a user-defined type guard function like:
function inOperator<T extends object>(key: any, obj: T): key is keyof T {
return key in obj;
}
which enables the process()
function above to be written like
function process(obj: Foo, key: string) {
if (inOperator(key, obj)) {
console.log(obj[key].toUpperCase())
}
}
That compiles, and key
is narrowed to keyof Foo
, so obj[key]
is allowed and seen to be of type string
. Of course that still has the problem of unsoundness, so just... don't call process
with an obj
which has more properties than Foo
does, okay?