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

fix: bigint reviver #805

Merged
merged 1 commit into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions __tests__/utils/bigint.test.js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,41 @@ const bigIntObjSchema = z.object({

describe('test JSONBigInt', () => {
test('should parse numbers', () => {
// Doubles should be parsed normally as JS Numbers.
expect(JSONBigInt.parse('123')).toStrictEqual(123);
expect(JSONBigInt.parse('123.456')).toStrictEqual(123.456);
expect(JSONBigInt.parse('1.0')).toStrictEqual(1);
expect(JSONBigInt.parse('1.000000000000')).toStrictEqual(1);
expect(JSONBigInt.parse('12345678901234567890')).toStrictEqual(12345678901234567890n);
expect(JSONBigInt.parse('12345678901234567890.000')).toStrictEqual(12345678901234567890n);
pedroferreira1 marked this conversation as resolved.
Show resolved Hide resolved
expect(JSONBigInt.parse('1e2')).toStrictEqual(100);
expect(JSONBigInt.parse('1E2')).toStrictEqual(100);

expect(() => JSONBigInt.parse('12345678901234567890.1')).toThrow(
Error('large float will lose precision! in "12345678901234567890.1"')
);
pedroferreira1 marked this conversation as resolved.
Show resolved Hide resolved
// This is 2**53-1 which is the MAX_SAFE_INTEGER, so it remains a Number, not a BigInt.
// And the analogous for MIN_SAFE_INTEGER.
expect(JSONBigInt.parse('9007199254740991')).toStrictEqual(9007199254740991);
expect(JSONBigInt.parse('-9007199254740991')).toStrictEqual(-9007199254740991);

// One more than the MAX_SAFE_INTEGER, so it becomes a BigInt. And the analogous for MIN_SAFE_INTEGER.
expect(JSONBigInt.parse('9007199254740992')).toStrictEqual(9007199254740992n);
expect(JSONBigInt.parse('-9007199254740992')).toStrictEqual(-9007199254740992n);

// This is just a random large value that would lose precision as a Number.
expect(JSONBigInt.parse('12345678901234567890')).toStrictEqual(12345678901234567890n);

// This is 2n**63n, which is the max output value.
expect(JSONBigInt.parse('9223372036854775808')).toStrictEqual(9223372036854775808n);

// This is the value 2n**63n would have when converted to a Number with loss of precision,
// and then some variation around it. Notice it's actually greater than 2n**63n.
expect(JSONBigInt.parse('9223372036854776000')).toStrictEqual(9223372036854776000n);
expect(JSONBigInt.parse('9223372036854775998')).toStrictEqual(9223372036854775998n);
expect(JSONBigInt.parse('9223372036854775999')).toStrictEqual(9223372036854775999n);
expect(JSONBigInt.parse('9223372036854776001')).toStrictEqual(9223372036854776001n);
expect(JSONBigInt.parse('9223372036854776002')).toStrictEqual(9223372036854776002n);

// This is 2n**63n - 800n and the value it would have when converted to a Number with loss of precision.
// Notice it becomes less than the original value.
expect(JSONBigInt.parse('9223372036854775008')).toStrictEqual(9223372036854775008n);
expect(JSONBigInt.parse('9223372036854775000')).toStrictEqual(9223372036854775000n);
});

test('should parse normal JSON', () => {
Expand Down
62 changes: 29 additions & 33 deletions src/utils/bigint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,47 +19,43 @@
export const JSONBigInt = {
/* eslint-disable @typescript-eslint/no-explicit-any */
parse(text: string): any {
function bigIntReviver(_key: string, value: any, context: { source: string }): any {
if (!Number.isInteger(value)) {
// No special handling needed for non-integer values.
return value;
}

let { source } = context;
if (source.includes('e') || source.includes('E')) {
// Values with exponential notation (such as 10e2) are always Number.
return value;
}
// @ts-expect-error TypeScript hasn't been updated with the `context` argument from Node v22.
return JSON.parse(text, this.bigIntReviver);
},

if (source.includes('.')) {
// If value is an integer and contains a '.', it must be like '123.0', so we extract the integer part only.
let zeroes: string;
[source, zeroes] = source.split('.');
stringify(value: any, space?: string | number): string {
return JSON.stringify(value, this.bigIntReplacer, space);
},

if (zeroes.split('').some(char => char !== '0')) {
// This case shouldn't happen but we'll prohibit it to be safe. For example, if the source is
// '12345678901234567890.1', JS will parse it as an integer with loss of precision, `12345678901234567000`.
throw Error(`large float will lose precision! in "${text}"`);
}
}
bigIntReviver(_key: string, value: any, context: { source: string }): any {
if (typeof value !== 'number') {
// No special handling needed for non-number values.
return value;
}

const bigIntValue = BigInt(source);
if (bigIntValue !== BigInt(value)) {
// If the parsed value is an integer and its BigInt representation is a different value,
// it means we lost precision, so we return the BigInt.
try {
const bigIntValue = BigInt(context.source);
if (bigIntValue < Number.MIN_SAFE_INTEGER || bigIntValue > Number.MAX_SAFE_INTEGER) {
// We only return the value as a BigInt if it's in the unsafe range.
return bigIntValue;
}

// No special handling needed.
// Otherwise, we can keep it as a Number.
return value;
} catch (e) {
if (
e instanceof SyntaxError &&
e.message === `Cannot convert ${context.source} to a BigInt`
) {
// When this error happens, it means the number cannot be converted to a BigInt,
// so it's a double, for example '123.456' or '1e2'.
return value;
}
// This should never happen, any other error thrown by BigInt() is unexpected.
const logger = getDefaultLogger();
logger.error(`unexpected error in bigIntReviver: ${e}`);
throw e;

Check warning on line 57 in src/utils/bigint.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/bigint.ts#L55-L57

Added lines #L55 - L57 were not covered by tests
}

// @ts-expect-error TypeScript hasn't been updated with the `context` argument from Node v22.
return JSON.parse(text, bigIntReviver);
},

stringify(value: any, space?: string | number): string {
return JSON.stringify(value, this.bigIntReplacer, space);
},

bigIntReplacer(_key: string, value_: any): any {
Expand Down
Loading