Expanding a JSON type in Typescript to accommodate interfaces

Expanding on discussions about passing interfaces to functions expecting JSON types in Typescript, this question delves into the complexities surrounding JSON TypeScript type. The scenario involves a JSONValue type that encompasses various data types like string, number, boolean, null, arrays of JSONValues, and key-value pairs with string keys and JSON values.

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

A previous discussion highlighted the limitation of passing an interface like Foo to a function expecting a JSONValue, even when it seems logically sound. The code snippet provided demonstrates this issue where an attempt to pass an interface Foo to a function expecting JSONValue fails due to compatibility constraints.

The suggestion from earlier responses was to widen the JSONValue type or convert interfaces to types as a workaround. However, widening the JSONValue type raises questions about the implications and potential changes required across interfaces in a given context.

This dilemma points towards the need for further exploration into handling interfaces and JSON types effectively within Typescript. For more insights and discussions around this topic, refer to the provided links and resources.

Answer №1

Originally, my intention in the response was to relax the type JSONValue. Instead, opting for the object type would suffice.

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

However, by doing so, you compromise type safety as the function now accepts types that should be considered invalid, such as

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

This interface includes a property fn with a function type. Ideally, we want to restrict this type from being passed to the function.


There is still hope though. We can resort to one final option: infer the types into a generic type and recursively validate them.

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 analyzes a given type T by traversing through its properties. It deems the property as never if it is not valid.

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

type Validated = ValidateJSON<Foo>
// {
//     name: 'FOO';
//     fooProp: string;
//     fn: never;
// }

We can utilize this utility type to validate both the parameter type and the return type of fn within the scope 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);
}

All of this culminates in the following behavior:

// ok
wrap(
  (foo: Foo) => { return foo }, 
  { name: 'FOO', fooProp: 'hello' }
);

// not ok, foo contains a parameter type that involves a function
wrap(
  (foo: Foo & { fn: () => void }) => { return foo }, 
  { name: 'FOO', fooProp: 'hello', fn: () => {} }
);

// not ok, fn returns an object with a function
wrap(
  (foo: Foo) => { return { ...foo, fn: () => {} } }, 
  { name: 'FOO', fooProp: 'hello' }
);

// not ok, foo contains a parameter type that includes undefined
wrap(
  (foo: Foo & { c: undefined }) => { return foo }, 
  { name: 'FOO', fooProp: 'hello', c: undefined }
);

Playground

Answer №2

Incorporating a separate generic for the return type, which also extends JsonValue, resolves any type compatibility issues and allows for inferring a more specific 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'});  // okay 👍
    //^? const result: Foo

Regarding the JsonValue type you presented: for the union member with string keys and JsonValue values (object type), it is more accurate to include undefined in a union with JsonValue. This makes the keys effectively optional, as there will not always be a value at every key in an object. The resulting value from accessing a non-existent key on an object is undefined at runtime:

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

This approach aligns with the serialization and deserialization algorithms of the JSON object in JavaScript, where properties with undefined values are not serialized. Since JSON does not support undefined as a type, undefined never exists in a deserialized value.

This type is both practical (allowing dot notation property access for any property name) and safe (requiring each value to be narrowed to JsonValue before using it).

Full code in TS Playground


Response update to your comment:

The playground answer still relies on interface Foo being a type. I require it to be an interface. Is this feasible?

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

The TS handbook section Differences Between Type Aliases and Interfaces emphasizes this point:

Type aliases and interfaces are quite similar, with the main difference being that a type cannot be extended to add new properties, unlike an interface which can always be extended.

This means that a type alias is finalized where it's defined, while an interface's exact shape is determined during type-checking because it can be altered elsewhere. To restrict changes to an interface, constraints must be applied:

An example using an unconstrained interface showcases a 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 confined to ensure compatibility with the object-like union member of JsonValue, no potential type conflicts arise:

type JsonObject = { [key: string]: JsonValue | undefined };

interface Foo extends JsonObject {
  name: 'FOO';
  fooProp: string;
}

const bar = (foo: Foo) => foo;

const result = wrap(bar, { name: 'FOO', fooProp: 'hello'});  // okay 👍

Code in TS Playground

For further information, refer to utility types in the TS handbook

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Is there a way to easily toggle a Material Checkbox in Angular with just one click?

Issue with Checkbox Functionality: In a Material Dialog Component, I have implemented several Material Checkboxes to serve as column filters for a table: <h1 mat-dialog-title>Filter</h1> <div mat-dialog-content> <ng-container *ng ...

Displaying Typescript command line options during the build process in Visual Studio

As I delve into the world of VS 2015 Typescript projects, I find myself faced with a myriad of build options. Many times, the questions and answers on Stack Overflow mention command line options that I'm not completely familiar with, especially when i ...

Preserving the most recent choice made in a dropdown menu

Just started with angular and facing an issue saving the select option tag - the language is saved successfully, but the select option always displays English by default even if I select Arabic. The page refreshes and goes back to English. Any assistance o ...

Proper approach for mapping JSON data to a table using the Fetch API in a React.js application

Struggling to map some elements of JSON to a Table in Customers.jsx, but can't seem to figure out the correct way. How do I properly insert my Fetch Method into Customers.jsx? Specifically managing the renderBody part and the bodyData={/The JsonData/} ...

submitting a JObject as a payload to a request

I just came across an interesting article by Rick Strahl on passing multiple POST parameters to Web API Controller methods. You can check it out here. In the article, he talks about using JObject in the action and provides an example of a controller imple ...

The proper way to send a string array parameter to an MVC WebApi controller

Starting my journey with AngularJS, I'm faced with the challenge of passing a JSON object that includes an array of strings to an MVC WebApi GET method. However, no matter what I try, I cannot seem to get the WebAPI controller to receive the correct v ...

What is the best way to eliminate the additional square bracket from a JSON file containing multiple arrays?

Seeking JSON data from an array, I encountered an issue with an extra square bracket appearing in the output. After attempting $episode[0] = $podcast->getPodcastByCategoryId($id);, only partial data was retrieved, corresponding to the first iteration. ...

Tips for determining the datatype of a callback parameter based on the specified event name

Let's say we have the following code snippet: type eventType = "ready" | "buzz"; type eventTypeReadyInput = {appIsReady: string}; interface mysdk { on:(event: eventType, cb: (input: eventTypeCallbackInput) => void) => void } mysdk.on("ready", ...

Power Query M - Tackling Expression Error in Transforming Lists to Text

In my code, I'm using Web.Contents to make an API request. The access token I pass as a parameter is dynamically generated by a separate function. let Source = Json.Document( Web.Contents( {"https://api-url.com/endpoint/id"} ...

Typescript - optional type when a generic is not given

I am hoping for optionalFields to be of type OptionalFieldsByTopic<Topic> if a generic is not provided, or else OptionalFieldsByTopic<T>. Thank you in advance for the assistance. export interface ICreateItem<T extends Topic = never> { // ...

I continue to encounter the same error while attempting to deliver data to this form

Encountering an error that says: TypeError: Cannot read properties of null (reading 'persist') useEffect(() => { if (edit) { console.log(item) setValues(item!); } document.body.style.overflow = showModal ? "hidden ...

A guide for implementing fast-json-patch in your Angular 2 projects

Looking to incorporate the "fast-json-patch" library into my Angular 2 project. This library can be found at https://github.com/Starcounter-Jack/JSON-Patch. I attempted to include the following code: 'fast-json-patch': 'vendor/fast-json-pa ...

Exploring Computed Properties in Angular Models

We are currently in the process of developing an application that involves the following models: interface IEmployee{ firstName?: string; lastName?: string; } export class Employee implements IEmployee{ public firstName?: string; public l ...

What is the best way to declare a TypeScript type with a repetitive structure?

My data type is structured in the following format: type Location=`${number},${number};${number},${number};...` I am wondering if there is a utility type similar to Repeat<T> that can simplify this for me. For example, could I achieve the same resul ...

Using Typescript with d3 Library in Power BI

Creating d3.axis() or any other d3 object in typescript for a Power BI custom visual and ensuring it displays on the screen - how can this be achieved? ...

Performing a task through an AJAX call in DNN MVC

During my DNN MVC development journey, I encountered another issue. I am unsure if this is a bug, a missing feature, or a mistake on my part. Let me elaborate on the problem below. My Objective I aim to trigger an action in my controller by making an AJA ...

Learn how to access nested arrays within an array in React using TypeScript without having to manually specify the type of ID

interface UserInformation { id:number; question: string; updated_at: string; deleted_at: string; old_question_id: string; horizontal: number; type_id: number; solving_explanation:string; ...

Guide to sending a HTTP POST request with parameters in typescript

I need assistance sending a POST request using parameters in the following format: http://127.0.0.1:9000/api?command={"command":"value","params":{"key":"value","key":"value","key":"value","key":value,}} I attempted to do this but encountered an issue: l ...

Troubleshooting npm installation issues on Ubuntu 18.04

While attempting to set up Hooper for a Vue.js example, I used the command: $ npm install hooper This resulted in the following error message: npm ERR! file /home/juanlh/package.json npm ERR! code EJSONPARSE npm ERR! JSON.parse Failed to parse json npm ...

Creating sparse fieldset URL query parameters using JavaScript

Is there a way to send type-related parameters in a sparse fieldset format? I need help constructing the URL below: const page = { limit: 0, offset:10, type: { name: 's', age:'n' } } I attempted to convert the above ...