1

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.

jcalz
  • 264,269
  • 27
  • 359
  • 360
theahura
  • 353
  • 2
  • 19
  • 1
    Have you tried to pass it to `unknown` and then to `Foo` or `Bar` `(data as unknown) as Foo` – jjchiw Aug 08 '22 at 15:56
  • 2
    rewrite `data as Foo` to `data as unknown as Foo` – Tobias S. Aug 08 '22 at 15:57
  • yes I could just follow the TS error warning, but I'd rather not do that -- it would be better if TS actually understood the comparison, and I want to know if thats even possible – theahura Aug 08 '22 at 15:58
  • I don't think that's possible, maybe you should try to create a `toFoo` or `toBar` method or maybe something like `{...data} as Foo` (I'm not sure of this one)... – jjchiw Aug 08 '22 at 16:12
  • not a bad idea! though this does incur an additional copy cost for something that should be a 'free' typecast, and I think this doesn't work so well in the other direction – theahura Aug 08 '22 at 16:15
  • TypeScript doesn't narrow automatically by checking non-union types like `JSONValue` for having properties or what those properties contain; see [ms/TS#21732](https://github.com/microsoft/TypeScript/issues/21732). You could write a user-defined type guard function to let the compiler know that's what you're doing with the check, like [this playground link](https://tsplay.dev/wQ8P7W) shows. Does that meet your needs? If so I could write up an answer; if not, what am I missing? – jcalz Aug 08 '22 at 20:15
  • Thanks for the response @jcalz. One thing I don't follow in the playground link: how does the `hasKeyVal` function know about interfaces Foo and Bar? They don't get passed in implicitly or explicitly, so is the `genericCall` function doing some kind of narrowing there? – theahura Aug 08 '22 at 23:52
  • When `hasKeyVal(data, "name", "FOO")` returns `true`, then `data` gets narrowed from `{[k: string]: JSONValue}` to {name: "FOO"}` which is [structurally](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html#structural-type-system) identical to `Foo` so it compiles. Does that makes sense? Should I write up an answer explaining this or is there some outstanding issue that you would still need addressed? – jcalz Aug 09 '22 at 00:25
  • ah understood. This definitely answers the minimum reproducible example above, but in practice Foo and Bar are placeholders for more complex interfaces. If I add another field to Foo, the playground link above no longer works. Edited the question to reflect this – theahura Aug 09 '22 at 00:32
  • Have you considered having your interfaces extend `Record`? – smac89 Aug 09 '22 at 01:20
  • 1
    Can you edit your [mre] to reflect the new requirements also? It should at least work to do a type assertion like [this](https://tsplay.dev/N91dJN), right? I mean, you're not actually checking that `data` is a `Foo` then, just if it has the right `name` property, so we can't expect the compiler to verify that it is a `Foo`. You can assert or you can really test. Which one did you want to do for this question? – jcalz Aug 09 '22 at 01:52
  • O neat, yea the type assertion is fine. Didn't realize that we can just cast using `as` at that point. If you submit that as an answer, happy to approve it – theahura Aug 09 '22 at 02:22

1 Answers1

1

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).

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360