skip to Main Content

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?

2

Answers


  1. If you use a separate generic for the return type (which also extends JsonValue, then you won’t have a type compatibility issue, and you can also infer a more narrow return type:

    function wrap <A extends JsonValue[], T extends JsonValue>(
      fn: (...args: A) => T,
      ...args: A
    ): T {
      return fn(...args);
    }
    
    const result = wrap(bar, { name: 'FOO', fooProp: 'hello'});  // ok 👍
        //^? const result: Foo
    

    On the JsonValue type that you showed: for the union member which has string keys and JsonValue values (the object type): it is more correct to include undefined in a union with JsonValue, making the keys effectively optional… because there will never be a value at every key in an object, and the value that results from accessing a key that doesn’t exist on an object is undefined at runtime:

    type JsonValue =
      | string
      | number
      | boolean
      | null
      | JsonValue[]
      | { [key: string]: JsonValue | undefined };
    //                             ^^^^^^^^^^^
    

    This is compatible with the serialization and deserialization algorithms of the JSON object in JavaScript because it neither serializes properties with undefined as a value, nor does JSON support undefined as a type, so undefined will never exist in a deserialized value.

    This type is both convenient (you can use dot notation property access for any property name) and type-safe (you must narrow each value to be JsonValue to safely use it as such).

    Full code in TS Playground


    Update in response to your comment:

    the answer in the playground still depends on interface Foo being a type. But I need it to be an interface. Is this possible?

    It is only possible if the interface extends (is constrained by) the object-like union member of JsonValue.

    The TS handbook section Differences Between Type Aliases and Interfaces begins with this information (the emphasis is mine):

    Type aliases and interfaces are very similar, and in many cases you can choose between them freely. Almost all features of an interface are available in type, the key distinction is that a type cannot be re-opened to add new properties vs an interface which is always extendable.

    What this means is that each type alias is finalized at the site where it’s defined… but an interface can always be extended (mutated), so the exact shape is not actually finalized until type-checking happens because other code (e.g. other code that consumes your code) can change it.

    The only way to prevent this is to constrain which extensions are allowed for the interface in question:

    An example using your original, unconstrained interface shows the compiler error:

    interface Foo {
      name: 'FOO';
      fooProp: string;
    }
    
    const bar = (foo: Foo) => foo;
    
    const result = wrap(bar, { name: 'FOO', fooProp: 'hello'});  /*
                        ~~~
    Argument of type '(foo: Foo) => Foo' is not assignable to parameter of type '(...args: JsonValue[]) => JsonValue'.
      Types of parameters 'foo' and 'args' are incompatible.
        Type 'JsonValue' is not assignable to type 'Foo'.
          Type 'null' is not assignable to type 'Foo1'.(2345) */
    

    However, if the interface is constrained to only allow compatibility with the object-like union member of JsonValue, then there are no potential type compatibility issues:

    type JsonObject = { [key: string]: JsonValue | undefined };
    //^ This type could be written with built-in type utilities a number of ways.
    // I like this equivalent syntax:
    // type JsonObject = Partial<Record<string, JsonValue>>;
    
    interface Foo extends JsonObject {
      name: 'FOO';
      fooProp: string;
    }
    
    const bar = (foo: Foo) => foo;
    
    const result = wrap(bar, { name: 'FOO', fooProp: 'hello'});  // ok 👍
    

    Code in TS Playground

    See also: utility types
    in the TS handbook

    Login or Signup to reply.
  2. 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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search