I believe this may be along the same lines of Typescript - union types, Issue with TypeScript typing, and Typescript property does not exist on union type but none of them are the exact same. When a union type is created from Object.entries...map it seems to remove keys that aren't shared. Here's some example code:
type Params = {
head1:{
sub1:{
a:string
}},
head2:{
sub2:{
a:string,
b:number
}}}
(params:Params)=>{
Object.entries(params).map(([headkey, sub])=>{
Object.entries(sub).map(([subkey, subval])=>{
subval.a // <- Works fine
if('b' in subval)
subval.b // <- Error, "Property 'b' does not exist on type '{ a: string; }'"
})
})
}
Playground
Is there any way to overcome this? Is this a bug?
It's not a bug, although it has effects you don't like in this situation. When the compiler has to infer a single type from several other types, it will try to synthesize what is called "the best common type". Often this will be a direct union of all the types involved:
interface X { x: string }
declare const x: X;
interface Y { y: string; }
declare const y: Y;
const eitherXY = Math.random() < 0.5 ? x : y
// const eitherXY: X | Y
But sometimes at least one of the types involved will be assignable to one of the other types involved, and in these cases the compiler will infer just the single, more general type instead of a union:
interface Z { x: string, z: number }
declare const z: Z;
const eitherXZ = Math.random() < 0.5 ? x : z
// const eitherXZ: X // <-- this is X, not X | Z
This works because even though it's not declared as such, Z extends X. Object types in TypeScript are extendible via adding of properties, due to structural subtyping. And so the compiler decides to infer just X instead of X | Z.
Now in a world where TypeScript had a perfectly correct type system based on structural subtyping, there might be no observable difference between the union X | Z and just X if Z extends X. After all, one might think about types as corresponding to the set of all JavaScript values assignable to that type. If Z extends X then every value of type Z is also a value of type X, and thus X | Z and X correspond to the same set of values. In that world, the choice of X | Z and just X would purely be a matter of how the type is displayed.
But we don't live in that world.
One place X | Z is observably different from X is with excess property checking of object literals:
const xOrZ: X | Z = { x: "hello", z: 123 }; // okay
const justX: X = { x: "hello", z: 123 }; // error, excess property
Excess property warnings are technically false positives if you think of them as type errors, so perhaps it's better to think of them as a linter warning instead.
Another place is with when you use the in operator as a type guard:
declare const x: X;
if ("z" in x) {
x.z.toFixed(); // error
}
declare const xz: X | Z;
if ("z" in xz) {
xz.z.toFixed(); // okay
}
This is known to be technically incorrect. After all, the value {x: "", z: ""} should be a valid value of type X and therefore a valid value of type X | Z:
const hmm = { x: "", z: "" }
const alsoX: X = hmm; // okay
const alsoXZ: X | Z = hmm; // okay
So it should really be an error to imagine that the mere presence of a z property implies that it is a number. Neither of the above in type guards should work if we were being strict. But in practice it is very useful to assume that you can use in as a type guard (and people tend to want even more; just xz.z without first checking "z" in xz, but this has consistently been declined).
This all means that, depending on how you think about structural subtyping and unions, and whether or not it matches up with how the compiler treats a type or expression, you are likely to get correct-but-annoying warnings or incorrect-but-useful lack of warnings... neither of which are bugs.
So there's no bug here.
That said, what could you do to overcome it? In your case the problem is that Object.entries() has the following call signature:
/* (method) ObjectConstructor.entries<T>(o: {
[s: string]: T;
} | ArrayLike<T>): [string, T][] (+1 overload) */
And when you call it on sub, the compiler infers T as {a: string}, which is the best common supertype of {a: string} and {a: string, b: number}:
Object.entries(sub)
/* (method) ObjectConstructor.entries<{ a: string; }>(...) */
Therefore the most straightforward way to work around it is to explicitly specify T to be the union type you were expecting. You could either write it out as {a: string} | {a: string, b: number}, or you could make the compiler compute that type in terms of Params:
type SubSubParams =
{ [K in keyof Params]: Params[K][keyof Params[K]] }[keyof Params]
/* type SubSubParams = {
a: string;
} | {
a: string;
b: number;
} */
and then
Object.entries<SubSubParams>(sub).map(([subkey, subval]) => {
subval.a
if ('b' in subval)
subval.b // okay
})
Playground link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With