Skip to content

Commit

Permalink
Add JSON functions (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
lydell authored Oct 30, 2023
1 parent b93a9eb commit 272f982
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 2 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Decoded>(
codec: Codec<Decoded>,
jsonString: string,
): DecoderResult<Decoded>;

stringify<Decoded, Encoded>(
codec: Codec<Decoded, Encoded>,
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.
Expand Down
39 changes: 37 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ export type InferEncoded<T extends Codec<any>> = ReturnType<T["encoder"]>;
// https://stackoverflow.com/a/57683652/2010616
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;

const CodecJSON = {
parse<Decoded>(
codec: Codec<Decoded>,
jsonString: string,
): DecoderResult<Decoded> {
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<Decoded, Encoded>(
codec: Codec<Decoded, Encoded>,
value: Decoded,
space?: number | string,
): string {
return JSON.stringify(codec.encoder(value), null, space) ?? "null";
},
};

export { CodecJSON as JSON };

function identity<T>(value: T): T {
return value;
}
Expand Down Expand Up @@ -907,7 +940,7 @@ export type DecoderError = {
| {
tag: "custom";
message: string;
got: unknown;
got?: unknown;
}
| {
tag: "exact fields";
Expand Down Expand Up @@ -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;
}
}

Expand Down
112 changes: 112 additions & 0 deletions tests/JSON.test.ts
Original file line number Diff line number Diff line change
@@ -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<Decoded>(
decoderResult: DecoderResult<Decoded>,
): 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",
);
});
});

0 comments on commit 272f982

Please sign in to comment.