diff --git a/CHANGELOG.md b/CHANGELOG.md index 289271f..d161956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ Note: I’m currently working on several breaking changes to tiny-decoders, but I’m trying out releasing them piece by piece. The idea is that you can either upgrade version by version only having to deal with one or a few breaking changes at a time, or wait and do a bunch of them at the same time. +### Version 20.1.0 (unreleased) + +This release adds 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. Read more about it in the documentation. + ### Version 20.0.0 (2023-10-29) This release adds more support for primitives. diff --git a/README.md b/README.md index e41c0fe..07e32d8 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." + } + ] + } +} +``` + +> **Note** +> 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", + ); + }); +});