-
Notifications
You must be signed in to change notification settings - Fork 9
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
Bug in JSON.stringify replacer type. #36
Comments
This is false. For example, you can't stringify a bigint. JSON.stringify(0n, (_, value) => value); // TypeError: Do not know how to serialize a BigInt The type JSONValue = string | number | boolean | { [key: string]: JSONValue } | JSONValue[] | null; However, sometimes we want to stringify a value which isn't strictly JSON. For example, we might want to stringify an object which contains a const ana = <A>(value: A, replacer: (value: A) => JSONValueF<A>): JSONValue => {
const result = replacer(value);
if (result === null || typeof result !== 'object') return result;
if (Array.isArray(result)) return result.map((value) => ana(value, replacer));
return Object.fromEntries(
Object.entries(result).map(([key, value]): [string, JSONValue] => [
key,
ana(value, replacer),
])
);
}; As you can see, the type JSONValueF<A> = string | number | boolean | { [key: string]: A } | A[] | null; As you can see, type JSONValue = JSONValueF<JSONValue>
// Therefore, substituting `JSONValueF<JSONValue>` for `JSONValue` we get
type JSONValue = JSONValueF<JSONValueF<JSONValue>>
// Therefore, substituting `JSONValueF<JSONValue>` for `JSONValue` we get
type JSONValue = JSONValueF<JSONValueF<JSONValueF<JSONValue>>>
// Therefore, substituting `JSONValueF<JSONValue>` for `JSONValue` we get
type JSONValue = JSONValueF<JSONValueF<JSONValueF<JSONValueF<JSONValue>>>> As you can see, repeated applications of Anyway, coming back to the And, once you have a
The So, how do you actually use the Hope that clears your doubts. Let me know if there's anything else I can elucidate for you. |
I see the point you are making, but it is still not clear to me how to actually put this to use properly. In my actual use case, I want to do a normal export function jsonStringify(value: unknown, space?: string | number | undefined): string {
return JSON.stringify(value, (_, value) => {
if (typeof value === 'bigint') return `0x${value.toString(16)}n`
if (value instanceof Uint8Array) return `b'${Array.from(value).map(x => x.toString(16).padStart(2, '0')).join('')}'`
return value
}, space)
} My expectation is that other than adding support for I think the place my mental model is not aligning with your mental model is that I am of the opinion that the replacer function is not expected to handle all possible inputs, it is only meant to enhance the default Even if I accept that the export function jsonStringify(value: unknown, space?: string | number | undefined): string | undefined {
return JSON.stringify(value, (_, value) => {
if (typeof value === 'bigint') return `0x${value.toString(16)}n`
if (value instanceof Uint8Array) return `b'${Array.from(value).map(x => x.toString(16).padStart(2, '0')).join('')}'`
if (typeof value === 'string') return value
if (typeof value === 'number') return value
if (typeof value === 'boolean') return value
if (value === null) return value
if (Array.isArray(value)) return value
if (typeof value === 'object') return value as Record<string, unknown> // TODO: validate that all keys are strings
return undefined
}, space)
} Tangential question: Are there any cases where |
The easiest way to solve this problem is to strengthen the input type. Consider defining your own JSON-like type. export type MyJSONValue =
| JSONPrimitive
| bigint
| Uint8Array
| MyJSONValue[]
| { [key: string]: MyJSONValue }; Now you can easily define your export const jsonStringify = (value: MyJSONValue, space?: string | number): string =>
JSON.stringify(
value,
(_, value) => {
if (typeof value === 'bigint') return `0x${value.toString(16)}n`;
if (value instanceof Uint8Array)
return `b'${[...value]
.map((x) => x.toString(16).padStart(2, '0'))
.join('')}'`;
return value;
},
space
); This has several advantages.
However, if you don't want to strength the input type then you can weaken the output type. I see that you already figured out how to make
Both of these problems can be easily solved. To solve the first problem, we just need to create a new type predicate. export const isJSONValueF = (value: unknown): value is JSONValueF<unknown> =>
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'object'; Now, instead of writing several cases you can write a single case for JSON values. Next, to solve the second problem, we just throw an error if the return value of export const jsonStringify = (value: unknown, space?: string | number): string => {
const result = JSON.stringify(
value,
(_, value) => {
if (typeof value === 'bigint') return `0x${value.toString(16)}n`;
if (value instanceof Uint8Array)
return `b'${[...value]
.map((x) => x.toString(16).padStart(2, '0'))
.join('')}'`;
return isJSONValueF(value) ? value : undefined;
},
space
);
if (result === undefined)
throw new TypeError('The input value contains a function or a symbol');
return result;
}; Personally, I prefer strengthening the input type instead of weakening the output type. Strengthening the input type helps catch more bugs at compile time and doesn't require any extra JavaScript code.
Yes, it will also throw an error if your input value has circular references. |
In situations where you are stringifying a concretely typed thing, I agree that this approach is certainly the way to go. In my use case, this function is used to serialize thrown data for error reporting, where I don't have control over what is thrown (libraries can throw arbitrarily shaped things) and I need a reliable way to serialize that to a string. I can't think of how I could handle cycles other than by building my own serializer from scratch, but I can trivially handle
I believe the type definitions you have here don't protect against circular references either, so it seems that if one handles the I can appreciate, and support, the desire to give stronger type checking when you are serializing something with a shape known at compile time and I wouldn't want a change that breaks that. Perhaps an additional overload can be supplied for when |
If you can't strengthen the input then you have to weaken the output. That means, instead of writing
You don't handle cycles. Trying to stringify a cyclic data structure is an error. And, you don't handle errors. You fix them. So, don't worry about "handling" cyclic data structures.
Correct. The TypeScript type system is not sophisticated enough to be able to guard against circular references.
I don't see any reason to add an additional overload for Finally, the type Additionally, by forcing developers to return |
I think our disagreement comes down to a disagreement on what the "correct" function of the replacer is. This is valid code IMO, but it sounds like you are of the opinion it is not: JSON.stringify({ a: new Map(), b: new Set(), c: () => {}, d: 5n }, x => x) IMO, the job of the replacer isn't to definitely handle all possible inputs, but instead it is meant to supplement default If I follow your suggestion, are you confident that all default behavior would be retained? This includes things like arrays with function elements serializing as |
No, this code looks correct to me too.
No, I'm not of the opinion that the JSON.stringify(class FooBar {}, () => Math.E);
Yes, it works as expected. Playground Link. const result = JSON.stringify([() => {}], (_, x) => typeof x === 'function' ? undefined : x);
console.log(result); // "[null]" |
Yeah, I checked that one before posting but still included it as an example of a weird edge case. I'm curious if all such weird edge cases are handled correctly with your proposed solution, or if there will still be some differences in the output?
Sorry, I meant this (my previous example was definitely wrong), which fails to typecheck with JSON.stringify({ a: new Map(), b: new Set(), c: () => {}, d: 5n }, (_key, value) => value) |
Yes, all edge cases are handled correctly. If you return a non-JSON value from the
It also throws a |
If you know the type in advance, yes (and I can appreciate your argument that the code is incorrect because of that). These do not throw a runtime error though, and are also correct IMO (but this library prevents me from writing them): JSON.stringify({ a: new Map(), b: new Set(), c: () => {} }, (_key, value) => value)
JSON.stringify({ a: new Map(), b: new Set(), c: () => {}, d: 5n }, (_key, value) => typeof value === 'bigint' ? value.toString() : value) |
There's a flaw in your reasoning though. You're assuming that what you want is also what everybody else wants. Consider the following data. const exhibitA = {
foo: new Map([[0, 1], [1, 2], [2, 4], [3, 8], [4, 16], [5, 31]])
}; We want to serialize it. Hence, we use const exhibitB = {
bar: new Set([1, 2, 4, 8, 16, 31])
}; However, we forget to update the This is a bug which could have easily been caught at compile time. So, there's real value in forcing developers to only return a value which satisfies If you don't want to return a JSON-like value, then you can explicitly return |
Your reference to The Zen of Python makes me chuckle given how amazingly un-typed (dynamic) Python is. 😆 I agree with you that if a user is leveraging the structural typing you have created here, then a change as you suggested should result in an error and I would not want any change to support my use case to break that. This is why ideally I would like (but maybe it isn't possible) an overload specifically for Are you of the opinion that there isn't a way to add an overload that is a bit more permissive specifically for the |
You've not provided any convincing reason to add an overload for
Indeed. I see no way to add an overload for |
Code:
Type error:
I'm pretty sure this code is sound/correct, as you can always return the input value as output from the replacer function (it is the expected behavior for anything you don't want to replace), so this should not result in an error. The
value
parameter should be a subset type of the return type for the replacer function, but as things are currently definedA
is not assignable toJSONValueF<A>
.One maybe solution that appears to work is changing:
to
Another maybe solution that appears to work is changing:
to
I have no idea if these changes have affects outside of
JSON.stringify
, or if they are the right way to address this problem. They are just me poking around trying to figure out the issue.The text was updated successfully, but these errors were encountered: