From 5058d01f309221529f7f3c29e7529dac8acb4ab4 Mon Sep 17 00:00:00 2001 From: Ronald Veth Date: Tue, 24 Mar 2026 13:49:16 +0100 Subject: [PATCH 1/2] feat: add preserveUnsafeIntegersAsString option to prevent precision loss for large integers --- README.md | 17 +++++++++++++++++ src/csv-stream-parser.ts | 18 ++++++++++++++---- src/nested-json-converter.ts | 18 ++++++++++++++---- src/types.ts | 10 ++++++++++ tests/csv-stream-parser.test.ts | 32 ++++++++++++++++++++++++++++++++ tests/edge-cases.test.ts | 30 ++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fce383e..5251ea8 100644 --- a/README.md +++ b/README.md @@ -903,6 +903,7 @@ interface CsvParserOptions { // Value transformations autoParseNumbers?: boolean; // Default: false + preserveUnsafeIntegersAsString?: boolean; // Default: false autoParseBooleans?: boolean; // Default: false autoParseDates?: boolean; // Default: false valueTransformer?: (value, header) => any; // Custom value transformer @@ -978,6 +979,22 @@ Automatically remove BOM (Byte Order Mark) from the beginning of content. Defaul Automatically convert numeric strings to numbers. Strings with leading zeros (like `"007"`) are preserved. +Note: JavaScript numbers lose integer precision above `Number.MAX_SAFE_INTEGER` (`9007199254740991`). +If you want to prevent precision loss for large integers, enable `preserveUnsafeIntegersAsString`. + +#### `preserveUnsafeIntegersAsString` + +When used with `autoParseNumbers`, keeps integers outside JavaScript's safe integer range as strings. + +```typescript +const result = CsvParser.parseString(csv, { + autoParseNumbers: true, + preserveUnsafeIntegersAsString: true +}); + +// "9007199254740993" stays a string to avoid precision loss +``` + #### `autoParseBooleans` Automatically convert `'true'`/`'false'` strings to booleans (case-insensitive). diff --git a/src/csv-stream-parser.ts b/src/csv-stream-parser.ts index b3c10fa..076d2b4 100644 --- a/src/csv-stream-parser.ts +++ b/src/csv-stream-parser.ts @@ -557,8 +557,15 @@ export class CsvStreamParser extends Transform { private applyTransformations( record: Record ): Record { - const { autoParseNumbers, autoParseBooleans, autoParseDates, valueTransformer, nullValues, nullRepresentation } = - this.options; + const { + autoParseNumbers, + preserveUnsafeIntegersAsString, + autoParseBooleans, + autoParseDates, + valueTransformer, + nullValues, + nullRepresentation, + } = this.options; if (!autoParseNumbers && !autoParseBooleans && !autoParseDates && !valueTransformer && nullValues === undefined) { return record; @@ -594,7 +601,7 @@ export class CsvStreamParser extends Transform { // Auto-parse numbers if (autoParseNumbers) { - const parsed = this.tryParseNumber(value); + const parsed = this.tryParseNumber(value, preserveUnsafeIntegersAsString); if (parsed !== null) { transformedValue = parsed; } @@ -652,12 +659,15 @@ export class CsvStreamParser extends Transform { /** * Try to parse a string as a number. */ - private tryParseNumber(value: string): number | null { + private tryParseNumber(value: string, preserveUnsafeIntegersAsString?: boolean): number | string | null { if (value.trim() === "") return null; if (/^0\d+$/.test(value)) return null; const parsed = Number(value); if (!Number.isNaN(parsed) && Number.isFinite(parsed)) { + if (preserveUnsafeIntegersAsString && /^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) { + return value; + } return parsed; } return null; diff --git a/src/nested-json-converter.ts b/src/nested-json-converter.ts index 27aa0be..dabe1d1 100644 --- a/src/nested-json-converter.ts +++ b/src/nested-json-converter.ts @@ -133,8 +133,15 @@ export class NestedJsonConverter { records: CsvRecord[], options: CsvParserOptions ): Record[] { - const { autoParseNumbers, autoParseBooleans, autoParseDates, valueTransformer, nullValues, nullRepresentation } = - options; + const { + autoParseNumbers, + preserveUnsafeIntegersAsString, + autoParseBooleans, + autoParseDates, + valueTransformer, + nullValues, + nullRepresentation, + } = options; // Default null values const nullSet = new Set((nullValues ?? ["null", "NULL", "nil", "NIL"]).map(v => v.toLowerCase())); @@ -175,7 +182,7 @@ export class NestedJsonConverter { // Step 1: Auto-parse numbers if (autoParseNumbers) { - const parsed = this.tryParseNumber(value); + const parsed = this.tryParseNumber(value, preserveUnsafeIntegersAsString); if (parsed !== null) { transformedValue = parsed; } @@ -235,7 +242,7 @@ export class NestedJsonConverter { * Try to parse a string as a number. * Returns null if the string is not a valid number. */ - private static tryParseNumber(value: string): number | null { + private static tryParseNumber(value: string, preserveUnsafeIntegersAsString?: boolean): number | string | null { // Don't parse empty strings or whitespace-only if (value.trim() === "") return null; @@ -247,6 +254,9 @@ export class NestedJsonConverter { // Check if it's a valid finite number if (!Number.isNaN(parsed) && Number.isFinite(parsed)) { + if (preserveUnsafeIntegersAsString && /^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) { + return value; + } return parsed; } diff --git a/src/types.ts b/src/types.ts index 0746ed5..911b151 100644 --- a/src/types.ts +++ b/src/types.ts @@ -316,6 +316,16 @@ export interface CsvParserOptions { */ autoParseNumbers?: boolean; + /** + * Preserve integer values outside JavaScript safe integer range as strings. + * Helps prevent precision loss when autoParseNumbers is enabled. + * @default false + * + * @remarks + * Only applies to whole numbers where `Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER`. + */ + preserveUnsafeIntegersAsString?: boolean; + /** * Automatically convert 'true'/'false' strings to booleans. * Case-insensitive matching. diff --git a/tests/csv-stream-parser.test.ts b/tests/csv-stream-parser.test.ts index 8690309..3f854f1 100644 --- a/tests/csv-stream-parser.test.ts +++ b/tests/csv-stream-parser.test.ts @@ -252,6 +252,38 @@ id,name,age expect(records).toEqual([{ id: 1, code: "007" }]); }); + it("should preserve unsafe integers as strings when configured", async () => { + const csvContent = `id,big +1,9007199254740993`; + const stream = Readable.from([csvContent]); + const parser = new CsvStreamParser({ + autoParseNumbers: true, + preserveUnsafeIntegersAsString: true, + }); + + const records: NestedObject[] = []; + for await (const record of stream.pipe(parser)) { + records.push(record as NestedObject); + } + + expect(records).toEqual([{ id: 1, big: "9007199254740993" }]); + }); + + it("should keep current behavior for unsafe integers by default", async () => { + const csvContent = `id,big +1,9007199254740993`; + const stream = Readable.from([csvContent]); + const parser = new CsvStreamParser({ autoParseNumbers: true }); + + const records: NestedObject[] = []; + for await (const record of stream.pipe(parser)) { + records.push(record as NestedObject); + } + + expect(typeof records[0].big).toBe("number"); + expect(String(records[0].big)).toBe("9007199254740992"); + }); + it("should auto-parse booleans when enabled", async () => { const csvContent = `id,active,verified 1,true,FALSE`; diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index cfdbcf5..1b2558d 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -283,6 +283,36 @@ describe("Edge Cases", () => { expect(result[1].code).toBe("00123"); }); + it("should preserve unsafe integers as strings when configured", () => { + const csv = "id,big\n1,9007199254740993"; + const result = CsvParser.parseString(csv, { + autoParseNumbers: true, + preserveUnsafeIntegersAsString: true, + }); + + expect(result[0].big).toBe("9007199254740993"); + expect(typeof result[0].big).toBe("string"); + }); + + it("should keep current behavior for unsafe integers by default", () => { + const csv = "id,big\n1,9007199254740993"; + const result = CsvParser.parseString(csv, { autoParseNumbers: true }); + + expect(typeof result[0].big).toBe("number"); + expect(String(result[0].big)).toBe("9007199254740992"); + }); + + it("should still parse safe integers as numbers with unsafe-integer preservation enabled", () => { + const csv = "id,count\n1,9007199254740991"; + const result = CsvParser.parseString(csv, { + autoParseNumbers: true, + preserveUnsafeIntegersAsString: true, + }); + + expect(result[0].count).toBe(9007199254740991); + expect(typeof result[0].count).toBe("number"); + }); + it("should auto-parse booleans (case-insensitive)", () => { const csv = "id,active,verified\n1,true,FALSE\n2,True,false"; const result = CsvParser.parseString(csv, { autoParseBooleans: true }); From d52ec65ba3e69effd89be30bd75c476f9bd45115 Mon Sep 17 00:00:00 2001 From: Ronald Veth Date: Tue, 24 Mar 2026 13:56:09 +0100 Subject: [PATCH 2/2] feat: add preserveUnsafeIntegersAsString option to prevent precision loss for large integers --- .changeset/sweet-candles-wave.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/sweet-candles-wave.md diff --git a/.changeset/sweet-candles-wave.md b/.changeset/sweet-candles-wave.md new file mode 100644 index 0000000..bf4b92d --- /dev/null +++ b/.changeset/sweet-candles-wave.md @@ -0,0 +1,9 @@ +--- +"@cerios/csv-nested-json": patch +--- + +Add a new `preserveUnsafeIntegersAsString` option for number auto-parsing. + +When enabled together with `autoParseNumbers`, integer strings outside JavaScript's safe integer range are preserved as strings instead of being converted to imprecise numbers. + +This keeps existing behavior as the default and provides an opt-in path to prevent precision loss for large integer values in both regular and streaming parsers.