Search code examples
jsontypescripttypesinterfacecasting

Typescript: widening a JSON type to accept interfaces


This is an extension of Typescript: passing interface as parameter for a function that expects a JSON type (asking about passing interfaces to JSON typed functions), which in turn is an extension of Typescript: interface that extends a JSON type (asking about casting to/from JSON types)

These questions relate to a JSON Typescript type:

type JSONValue = 
 | string
 | number
 | boolean
 | null
 | JSONValue[]
 | {[key: string]: JSONValue}

In Typescript: passing interface as parameter for a function that expects a JSON type, the final answer indicates that it is not possible to pass an interface to a function that expects a JSON value. In particular, the following code:

interface Foo {
  name: 'FOO',
  fooProp: string
}

const bar = (foo: Foo) => { return foo }

const wrap = <T extends JSONValue[]>(
  fn: (...args: T) => JSONValue, 
  ...args: T
) => {
  return fn(...args);
}

wrap(bar, { name: 'FOO', fooProp: 'hello'});

fails because the interface Foo cannot be assigned to JSONValue even though analytically it is easy to recognize that the cast should be fine.

see playground, as well as https://github.com/microsoft/TypeScript/issues/15300

The previous answer stated:

The only workaround we have without widening the JSONValue type is to convert [interface] Foo to be a type.

In my case, I can modify the JSONValue type but cannot easily modify all of the relevant interfaces. What would widening the JSONValue type entail?


Solution

  • What I initially meant in my answer was to loosen the type JSONValue. You could settle for the object type.

    const wrap = <T extends object[]>(
      fn: (...args: T) => object, 
      ...args: T
    ) => {
      return fn(...args);
    }
    

    But you are essentially losing type safety as the function now accepts types which should be invalid like

    interface Foo { 
      name: 'FOO',
      fooProp: string,
      fn: () => void
    }
    

    which has a property fn with a function type. Ideally we would not allow this type to be passed to the function.


    But not all hope is lost. We have one option left: infer the types into a generic type and recursively validate it.

    type ValidateJSON<T> = {
      [K in keyof T]: T[K] extends JSONValue
        ? T[K]
        : T[K] extends Function  // we will blacklist the function type
          ? never
          : T[K] extends object
            ? ValidateJSON<T[K]>
            : never              // everything that is not an object type or part of JSONValue will resolve to never
    } extends infer U ? { [K in keyof U]: U[K] } : never
    

    ValidateJSON takes some type T and traverses through its type. It checks the property of the type and resolves them to never if the type should not be valid.

    interface Foo { 
      name: 'FOO',
      fooProp: string,
      fn: () => void
    }
    
    type Validated = ValidateJSON<Foo>
    // {
    //     name: 'FOO';
    //     fooProp: string;
    //     fn: never;
    // }
    

    We can use this utility type to validate both the parameter type and the return type of fn inside of wrap.

    const wrap = <T extends any[], R extends ValidateJSON<R>>(
      fn: (...args: T) => R, 
      ...args: { [K in keyof T]: ValidateJSON<T[K]> }
    ) => {
      return fn(...args as any);
    }
    

    Which all leads to the following behaviour:

    // ok
    wrap(
      (foo: Foo) => { return foo }, 
      { name: 'FOO', fooProp: 'hello' }
    );
    
    // not ok, foo has a parameter type which includes a function
    wrap(
      (foo: Foo & { fn: () => void }) => { return foo }, 
      { name: 'FOO', fooProp: 'hello', fn: () => {} }
    );
    
    // not ok, fn returns an object which includes a function
    wrap(
      (foo: Foo) => { return { ...foo, fn: () => {} } }, 
      { name: 'FOO', fooProp: 'hello' }
    );
    
    // not ok, foo has a parameter type which includes undefined
    wrap(
      (foo: Foo & { c: undefined }) => { return foo }, 
      { name: 'FOO', fooProp: 'hello', c: undefined }
    );
    

    Playground