Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💥 Add strong typing for JSON.parse when reviver is specified #123

Closed
wants to merge 17 commits into from
Closed

💥 Add strong typing for JSON.parse when reviver is specified #123

wants to merge 17 commits into from

Conversation

aaditmshah
Copy link

@aaditmshah aaditmshah commented Mar 23, 2023

This PR builds on top of #121. Here's a comparison of the two branches.

In response to “Rules we won't add

“Generics for JSON.parse, Response.json etc”

The primary concern is using generics to provide the return type of JSON.parse without providing a reviver argument. This is unsound, as you can see in the following code snippet.

const str = JSON.parse<string>("42");

console.log(str); // logs the number 42, which is not a string

This PR does not allow that. Following are the changes that I made to the type definition of JSON.parse.

interface JSON {
  parse(text: string): JsonValue;

  parse<A = unknown>(
    text: string,
    reviver: <K extends string>(
      this: JsonHolder<K, A>,
      key: K,
      value: JsonValueF<A>,
    ) => A,
  ): A;
}

And here are the type definitions of the utility types, JsonValue, JsonValueF, JsonHolder, etc.

type JsonPrimitive = string | number | boolean | null;

type JsonComposite<A> = Record<string, A> | A[];

type JsonValueF<A> = JsonPrimitive | JsonComposite<A>;

type JsonValue = JsonPrimitive | JsonObject | JsonValue[];

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

type JsonHolder<K extends string, A> = Record<K, JsonValueF<A>>;

A sound and type-safe way to use JSON.parse

The new JSON.parse type definitions introduced in this PR are both type-safe and sound. For example, the following code snippet is invalid.

const str = JSON.parse<string>("42"); // Invalid, because the argument for `reviver` was not provided

You can provide the return type of JSON.parse if and only if you also provide a valid argument for reviver. And, the reviver function has a sound type which constraints the return type that you can provide to JSON.parse.

This ensures that you never get an unsound result type when using JSON.parse. For example, if you only provide one argument to JSON.parse, i.e. the text that you want to parse, then the return type of the function is always JsonValue.

const val = JSON.parse("42"); // The result type is JsonValue, which is type-safe and sound

If you want, you can provide the return type of JSON.parse along with a reviver to get a different result.

type Option<A> = { value: A } | null;

const optionString = JSON.parse<Option<string>>("42", (key, value) =>
  typeof value === "string" ? { value } : null,
);

console.log(optionString); // logs null, because "42" is not parsed as a string

It's type-safe and sound because the return type provided for JSON.parse has to match both the input type and the return type of the reviver. Hence, you can't cheat.

type Option<A> = { value: A } | null;

const optionString = JSON.parse<Option<string>>("42", (key, value) =>
  typeof value === "number" ? { value } : null, // Invalid: expected a `string` but received a `number`
);

I firmly believe that you'd really have to go out of your way to break the type-safety provided by the type definitions in this PR.

What does the reviver do?

At its core, JSON.parse with a reviver is simply a structural fold, a.k.a. a catamorphism. It takes an initial F-algebra, i.e. the reviver, which describes how to convert values of type JsonValueF<A> into values of type A, and uses this description to convert any JsonValue into a value of type A.

This pure mathematical logic is muddied a little bit by the implementation of JSON.parse with respect to the reviver. For example, if the reviver returns undefined then the corresponding object property or array element is deleted. This could lead to sparse arrays. However, since the return type is generic, we don't need to change the type of the JSON.parse function. Garbage in, garbage out. If your reviver function can return undefined, then don't be surprised if you see undefined in the result.

Finally, the this context of the reviver is almost impossible to work with in a type-safe way. Hence, I gave it the very conservative type JsonHolder where JsonHolder<K extends string, A> = Record<K, JsonValueF<A>>. Essentially, if you have a reviver with the inputs this, key, and value, then the type of this[key] is the same as the type of value. The other properties, or array elements, of this could either be of the type JsonValue or of the return type A, depending upon whether or not they have been processed by the reviver. However, there's no safe way to access these properties or array elements. The best way to access them would be to use Object.entries or Object.values which return values of type unknown. Hence, there's no good reason to give this a more specific type to access the other object properties or array elements.

Conclusion

Hopefully, I've convinced you that it's indeed possible to provide a safe and sound type definition for JSON.parse. And there are many advantages in doing so. The result type of JSON.parse is no longer unknown, unless you use a reviver but forget to provide the return type. In addition, the reviver function provides more type-safety too. No more any types anywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant