The following code works just as expected (no errors),
export type AOrB = 'a' | 'b';
export const a: AOrB = 'a';
export const b: AOrB = 'b';
export const obj: ObjWithAOrB = {
[a]: 'foo',
[b]: 'bar',
};
However, when it is split up into 3 files, there's a type error on obj
saying the keys are of type string. What is causing this? Not sure if relevant, "isolatedModules": false"
.
This produces an error:
// types.ts
export type AOrB = 'a' | 'b';
export type ObjWithAOrB = {
[_ in AOrB]: any;
};
// constants.ts
import { AOrB } from './types';
export const a: AOrB = 'a';
export const b: AOrB = 'b';
// obj.ts
import { a, b } from './constants';
import { ObjWithAOrB } from './types';
export const obj: ObjWithAOrB = {
[a]: 'foo',
[b]: 'bar',
};
Error:
Type '{ [x: string]: string; }' is missing the following properties from type 'ObjWithAOrB': a, b
How come the constants got "downgraded" to strings?
CodePudding user response:
The reason why it works in your first example is that when you write this:
export const a: AOrB = 'a';
export const b: AOrB = 'b';
TypeScript uses control flow narrowing upon assignment. For the remainder of the code in the same scope, the apparent type of a
and b
will be narrowed from the union type AOrB
to the single literal type "a"
and "b"
respectively. You can see that with IntelliSense:
a // const a: "a"
b // const b: "b"
So when you export obj
from that same scope, the compiler knows that a
is "a"
and b
is "b"
and therefore that the computed keys [a]
and [b]
are single string literals a
and b
:
export const obj: ObjWithAOrB = {
[a]: 'foo',
[b]: 'bar',
}; // okay
Compare this to what happens when you split it up across module scopes. Inside your constants.ts
module, the types of a
and b
are seen as "a"
and "b"
. But when you import { a, b } from './constants'
, no control flow narrowing from the scope of constants.ts
persists. The imported a
and b
constants are only seen as being of the type you annotated them as: AOrB
:
import { a, b } from './constants';
a // (alias) const a: AOrB
b // (alias) const b: AOrB
The compiler has absolutely no idea that a
and b
might be narrower than their declared types. Control flow narrowing does not cross scopes the way you might expect. It would be prohibitively expensive to do so in general, because in most cases the compiler could not hope to figure out how to model control flow across different scopes. You understand that a const
cannot be reassigned and so any control flow narrowing due to assignment must be valid in other scopes, but the compiler does not special case this; if it did so it would need to start tracking such narrowings all over the place and in 99% of existing code it does not matter. The problem is intractable; for an interesting discussion of this sort of thing, see microsoft/TypeScript#9998, Trade-offs in Control Flow Analysis. One could imagine filing a feature request asking for const
exports from module scopes to maintain control-flow narrowings across boundaries, but I doubt it would get any traction, due to the reasons laid out in the linked issue.
Moving on: now your object literal has two computed keys, each of which is a union of string literals. In such cases the compiler gives up on representing the resulting as some union of object types, and instead widens the keys all the way to string
. This is the subject of microsoft/TypeScript#13948. Even if that issue were addressed, though, the best you could hope for would be a type like {a: string} | {b: string} | {a: string; b: string}
, and that type isn't assignable to ObjWithAOrB
which is required to definitely have both keys.
export const obj: ObjWithAOrB = {
[a]: 'foo',
[b]: 'bar',
}; // error!
So that's what is going on and why. As for what you should do about it: I'd say that you either want a
and b
to be seen externally as "a"
and "b"
respectively, in which case you shouldn't annotate it as something wider:
// constants.ts
export const a = 'a';
export const b = 'b';
// obj.ts
import { a, b } from './constants';
import { ObjWithAOrB } from './types';
export const obj: ObjWithAOrB = {
[a]: 'foo',
[b]: 'bar',
}; // okay
or you really do want a
and b
to be seen as AOrB
externally, in which case you need to assert to the compiler that a
is really of type "a"
and b
is really of type "b"
inside the object literal:
// obj.ts
import { a, b } from './constants';
import { ObjWithAOrB } from './types';
export const obj: ObjWithAOrB = {
[a as "a"]: 'foo',
[b as "b"]: 'bar',
}; // okay
Personally I'd go with the first approach. You might say "but I want the compiler to ensure that a
and b
are assignable to AOrB
", in which case that's a different issue: you're looking for microsoft/TypeScript#7481 because you want to check that a
and b
are assignable AOrB
without widening to AOrB
. You can work around it with a generic function (as mentioned in the linked issue), but I don't want to make this long answer even longer than it is by writing that out.