I am using a generic JSON type in typescript, suggested from here
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
I want to be able to cast from interface types that match JSON to and from the JSON type. For example:
interface Foo {
name: 'FOO',
fooProp: string
}
interface Bar {
name: 'BAR',
barProp: number;
}
const genericCall = (data: {[key: string]: JSONValue}): Foo | Bar | null => {
if ('name' in data && data['name'] === 'FOO')
return data as Foo;
else if ('name' in data && data['name'] === 'BAR')
return data as Bar;
return null;
}
This currently fails because Typescript does not see how the interface could be of the same type as JSONValue:
Conversion of type '{ [key: string]: JSONValue; }' to type 'Foo' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Property 'name' is missing in type '{ [key: string]: JSONValue; }' but required in type 'Foo'.
but analytically we of course know this is ok, because we recognize that at runtime types Foo and Bar are JSON compatible. How do I tell typescript that this is an ok cast?
ETA: I can follow the error message and cast to unknown first, but I'd rather not do that -- it would be better if TS actually understood the difference, and I'm wondering if it's possible at all.
The issue here is that the compiler does not use the check if ('name' in data && data['name'] === 'FOO')
to narrow the type of data
from its original type of {[key: string]: JSONValue}
. The type {[key: string]: JSONValue}
is not a union, and currently in
operator checks only narrow values of union types. There is an open feature request at microsoft/TypeScript#21732 to do such narrowing, but for now it's not part of the language.
That means data
stays of type {[key: string]: JSONValue}
after the check. When you then try to assert that data
is of type Foo
via data as Foo
, the compiler warns you that you might be making a mistake, because it doesn't see Foo
and {[key: string]: JSONValue}
are types that are related enough.
If you are sure that what you're doing is a good check, you could always do with the compiler suggests and type-assert to an intermediate type which is related to both Foo
and {[key: string]: JSONValue}
, such as unknown
:
return data as unknown as Foo; // okay
If that concerns you then you can write your own user defined type guard function which performs the sort of narrowing you expect from if ('name' in data && data['name'] === 'FOO')
. Essentially if that check passes, then we know that data
is of type {name: 'FOO'}
, which is related enough to Foo
for a type assertion. Here's a possible type guard function:
function hasKeyVal<K extends PropertyKey, V extends string | number |
boolean | null | undefined | bigint>(
obj: any, k: K, v: V): obj is { [P in K]: V } {
return obj && obj[k] === v;
}
So instead of if ('name' in data && data['name'] === 'FOO')
, you write if (hasKeyVal(data, 'name', 'FOO'))
. The return type obj is {[P in K]: V}
means that if the function returns true
, the compiler should narrow the type of obj
to something with a property whose key is of type K
and whose value is of type V
. Let's test it:
const genericCall = (data: { [key: string]: JSONValue }): Foo | Bar | null => {
if (hasKeyVal(data, 'name', 'FOO'))
return data as Foo; // okay, data is now {name: 'FOO'} which is related to Foo
else if (hasKeyVal(data, 'name', 'BAR'))
return data as Bar; // okay, data is now {name: 'BAR'} which is related to Bar
return null;
}
Now it works. The hasKeyVal()
check narrows data
to something with a name
property of the right type, and this is related enough to Foo
or Bar
for the type assertion to succeed (the type assertion is still necessary because a value of type {name: 'Foo'}
might not be a Foo
if Foo
has other properties).