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