Search code examples
jsontypescriptcasting

Typescript: interface that extends a JSON type


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.


Solution

  • 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