diff --git a/README.md b/README.md index e41c0fe..5e14fd5 100644 --- a/README.md +++ b/README.md @@ -1060,6 +1060,44 @@ Got: number It’s helpful when errors show you the actual values that failed decoding to make it easier to understand what happened. However, if you’re dealing with sensitive data, such as email addresses, passwords or social security numbers, you might not want that data to potentially appear in error logs. +## Replacement for JSON.parse and JSON.stringify + +```ts +const JSON: { + parse( + codec: Codec, + jsonString: string, + ): DecoderResult; + + stringify( + codec: Codec, + value: Decoded, + space?: number | string, + ): string; +}; +``` + +tiny-decoders exports a `JSON` object with `parse` and `stringify` methods, similar to the standard global `JSON` object. The difference is that tiny-decoder’s versions also take a `Codec`, which makes them safer. + +You can use ESLint’s [no-restricted-globals](https://eslint.org/docs/latest/rules/no-restricted-globals) rule to forbid the global `JSON` object, for maximum safety: + +```json +{ + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "JSON", + "message": "Import JSON from tiny-decoders and use its JSON.parse and JSON.stringify with a codec instead." + } + ] + } +} +``` + +> **Information** +> The standard `JSON.stringify` can return `undefined` (if you try to stringify `undefined` itself, or a function or a symbol). tiny-decoder’s `JSON.stringify` _always_ returns a string – it returns `"null"` for `undefined`, functions and symbols. + ## Type inference Rather than first defining the type and then defining the codec (which often feels like writing the type twice), you can _only_ define the decoder and then infer the type. diff --git a/index.ts b/index.ts index c772961..f1a2280 100644 --- a/index.ts +++ b/index.ts @@ -28,6 +28,39 @@ export type InferEncoded> = ReturnType; // https://stackoverflow.com/a/57683652/2010616 type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +const CodecJSON = { + parse( + codec: Codec, + jsonString: string, + ): DecoderResult { + let json: unknown; + try { + json = JSON.parse(jsonString); + } catch (unknownError) { + const error = unknownError as Error; // `JSON.parse` always throws `Error` instances. + return { + tag: "DecoderError", + error: { + tag: "custom", + message: `${error.name}: ${error.message}`, + path: [], + }, + }; + } + return codec.decoder(json); + }, + + stringify( + codec: Codec, + value: Decoded, + space?: number | string, + ): string { + return JSON.stringify(codec.encoder(value), null, space) ?? "null"; + }, +}; + +export { CodecJSON as JSON }; + function identity(value: T): T { return value; } @@ -907,7 +940,7 @@ export type DecoderError = { | { tag: "custom"; message: string; - got: unknown; + got?: unknown; } | { tag: "exact fields"; @@ -1040,7 +1073,9 @@ function formatDecoderErrorVariant( return `Expected ${variant.expected} items\nGot: ${variant.got}`; case "custom": - return `${variant.message}\nGot: ${formatGot(variant.got)}`; + return "got" in variant + ? `${variant.message}\nGot: ${formatGot(variant.got)}` + : variant.message; } } diff --git a/tests/JSON.test.ts b/tests/JSON.test.ts new file mode 100644 index 0000000..868b441 --- /dev/null +++ b/tests/JSON.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; + +import { + DecoderResult, + field, + fieldsAuto, + format, + JSON, + map, + number, + string, + unknown, +} from ".."; + +expect.addSnapshotSerializer({ + test: (value: unknown): boolean => typeof value === "string", + print: String, +}); + +function helper( + decoderResult: DecoderResult, +): Decoded | string { + switch (decoderResult.tag) { + case "DecoderError": + return format(decoderResult.error).replace( + /(SyntaxError:) .+/, + // To avoid slightly different error messages on different Node.js versions. + "$1 (the JSON parse error)", + ); + case "Valid": + return decoderResult.value; + } +} + +describe("JSON.parse", () => { + test("basic", () => { + const codec = fieldsAuto({ + lastName: field(string, { renameFrom: "last_name" }), + port: map(number, { + decoder: (value) => ({ tag: "Port" as const, value }), + encoder: (value) => value.value, + }), + }); + + expect(helper(JSON.parse(codec, `{"last_name": "Doe", "port": 1234}`))) + .toMatchInlineSnapshot(` + { + lastName: Doe, + port: { + tag: Port, + value: 1234, + }, + } + `); + + expect(helper(JSON.parse(codec, `{"lastName": "Doe", "port": 1234}`))) + .toMatchInlineSnapshot(` + At root: + Expected an object with a field called: "last_name" + Got: { + "lastName": "Doe", + "port": 1234 + } + `); + + expect(helper(JSON.parse(codec, `{"last_name": "Doe", "port": 1234`))) + .toMatchInlineSnapshot(` + At root: + SyntaxError: (the JSON parse error) + `); + }); +}); + +describe("JSON.stringify", () => { + test("basic", () => { + const codec = fieldsAuto({ + lastName: field(string, { renameFrom: "last_name" }), + port: map(number, { + decoder: (value) => ({ tag: "Port" as const, value }), + encoder: (value) => value.value, + }), + }); + + expect( + JSON.stringify( + codec, + { + lastName: "Doe", + port: { tag: "Port" as const, value: 1234 }, + }, + 2, + ), + ).toMatchInlineSnapshot(` + { + "last_name": "Doe", + "port": 1234 + } + `); + }); + + test("tab", () => { + expect(JSON.stringify(unknown, [1], "\t")).toBe("[\n\t1\n]"); + }); + + test("always returns a string", () => { + expect(JSON.stringify(unknown, undefined)).toMatchInlineSnapshot("null"); + expect(JSON.stringify(unknown, Symbol())).toMatchInlineSnapshot("null"); + expect(JSON.stringify(unknown, Function.prototype)).toMatchInlineSnapshot( + "null", + ); + }); +});