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

✨ Add strong typing for JSON.stringify #124

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4fe5505
:label: Add the `JsonValue` and `JsonObject` types
aaditmshah Mar 23, 2023
007168d
:label: Update the return type of `JSON.parse` to `JsonValue`
aaditmshah Mar 23, 2023
347aa1a
:white_check_mark: Update the `JSON.parse` return type test
aaditmshah Mar 23, 2023
c1787ab
:label: Update the return type of `.json()` to `Promise<JsonValue>`
aaditmshah Mar 23, 2023
6364af7
:white_check_mark: Update the `.json()` return type test
aaditmshah Mar 23, 2023
e2fab62
:memo: Update the readme to reflect the `JsonValue` change
aaditmshah Mar 23, 2023
25beb5f
:art: Move the fenced code block into the list item
aaditmshah Mar 23, 2023
c5e256a
:truck: Move `JsonValue` and `JsonObject` to a new file
aaditmshah Mar 23, 2023
bdda422
:rotating_light: Add `./json` to the exports map
aaditmshah Mar 23, 2023
3a278b6
:recycle: Change the order of `JsonValue[]` and `JsonObject`
aaditmshah Mar 23, 2023
a12acf4
:label: Add the `JsonPrimitive`, `JsonAlgebra`, and `JsonHolder` types
aaditmshah Mar 23, 2023
91c257a
:label: Update the type of `JSON.parse` when `reviver` is specified
aaditmshah Mar 23, 2023
d342523
:sparkles: Add type guard utility functions for testing `reviver`
aaditmshah Mar 23, 2023
c9b9c3d
:white_check_mark: Add positive and negative tests for `reviver`
aaditmshah Mar 23, 2023
4ed427b
:memo: Remove the section on not adding generics for `JSON.parse`
aaditmshah Mar 23, 2023
bab16a8
:label: Add the `JsonComposite` type and move `JsonAlgebra`
aaditmshah Mar 24, 2023
dc5f86e
:truck: Rename the type `JsonAlgebra` to `JsonValueF`
aaditmshah Mar 24, 2023
d7d75c3
:label: Add the `ToJson` utility type
aaditmshah Mar 24, 2023
11b18ee
:label: Update the type of `JSON.stringify`
aaditmshah Mar 24, 2023
395c006
:wrench: Add `./json-stringify` to the exports map
aaditmshah Mar 24, 2023
7a38a9c
:white_check_mark: Add positive and negative tests for `JSON.stringify`
aaditmshah Mar 25, 2023
23b704c
:pencil2: Fix typo in "Das Kapital" and add the adjective "famous"
aaditmshah Mar 25, 2023
ee15318
:label: Added `StringifyResult` and other utility types
aaditmshah Mar 27, 2023
e81d0a6
:label: Narrow the type of `JSON.stringify` if possible
aaditmshah Mar 27, 2023
ce4829b
:white_check_mark: Add more tests for `JSON.stringify`
aaditmshah Mar 27, 2023
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
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@
"import": "./dist/json-parse.mjs",
"default": "./dist/json-parse.js"
},
"./json-stringify": {
"types": "./dist/json-stringify.d.ts",
"import": "./dist/json-stringify.mjs",
"default": "./dist/json-stringify.js"
},
"./json": {
"types": "./dist/json.d.ts",
"import": "./dist/json.mjs",
"default": "./dist/json.js"
},
"./fetch": {
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.mjs",
Expand Down
56 changes: 15 additions & 41 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ TypeScript's built-in typings are not perfect. `ts-reset` makes them better.

**With `ts-reset`**:

- 👍 `.json` (in `fetch`) and `JSON.parse` both return `unknown`
- 👍 `.json` (in `fetch`) and `JSON.parse` both return `JsonValue`
- ✅ `.filter(Boolean)` behaves EXACTLY how you expect
- 🥹 `array.includes` is widened to be more ergonomic
- 🚀 And several more changes!
Expand All @@ -27,12 +27,12 @@ import "@total-typescript/ts-reset";
const filteredArray = [1, 2, undefined].filter(Boolean); // number[]

// Get rid of the any's in JSON.parse and fetch
const result = JSON.parse("{}"); // unknown
const result = JSON.parse("{}"); // JsonValue

fetch("/")
.then((res) => res.json())
.then((json) => {
console.log(json); // unknown
console.log(json); // JsonValue
});
```

Expand All @@ -42,10 +42,10 @@ fetch("/")

2. Create a `reset.d.ts` file in your project with these contents:

```ts
// Do not add any other lines of code to this file!
import "@total-typescript/ts-reset";
```
```ts
// Do not add any other lines of code to this file!
import "@total-typescript/ts-reset";
```

3. Enjoy improved typings across your _entire_ project.

Expand All @@ -56,10 +56,10 @@ By importing from `@total-typescript/ts-reset`, you're bundling _all_ the recomm
To only import the rules you want, you can import like so:

```ts
// Makes JSON.parse return unknown
// Makes JSON.parse return JsonValue
import "@total-typescript/ts-reset/json-parse";

// Makes await fetch().then(res => res.json()) return unknown
// Makes await fetch().then(res => res.json()) return JsonValue
import "@total-typescript/ts-reset/fetch";
```

Expand All @@ -75,7 +75,7 @@ Below is a full list of all the rules available.

## Rules

### Make `JSON.parse` return `unknown`
### Make `JSON.parse` return `JsonValue`

```ts
import "@total-typescript/ts-reset/json-parse";
Expand All @@ -88,16 +88,16 @@ import "@total-typescript/ts-reset/json-parse";
const result = JSON.parse("{}"); // any
```

By changing the result of `JSON.parse` to `unknown`, we're now forced to either validate the `unknown` to ensure it's the correct type (perhaps using [`zod`](https://github.com/colinhacks/zod)), or cast it with `as`.
By changing the result of `JSON.parse` to `JsonValue`, we're now forced to either validate the `JsonValue` to ensure it's the correct type (perhaps using [`zod`](https://github.com/colinhacks/zod)), or cast it with `as`.

```ts
// AFTER
import "@total-typescript/ts-reset/json-parse";

const result = JSON.parse("{}"); // unknown
const result = JSON.parse("{}"); // JsonValue
```

### Make `.json()` return `unknown`
### Make `.json()` return `JsonValue`

```ts
import "@total-typescript/ts-reset/fetch";
Expand All @@ -114,7 +114,7 @@ fetch("/")
});
```

By forcing `res.json` to return `unknown`, we're encouraged to distrust its results, making us more likely to validate the results of `fetch`.
By forcing `res.json` to return `JsonValue`, we're encouraged to distrust its results, making us more likely to validate the results of `fetch`.

```ts
// AFTER
Expand All @@ -123,7 +123,7 @@ import "@total-typescript/ts-reset/fetch";
fetch("/")
.then((res) => res.json())
.then((json) => {
console.log(json); // unknown
console.log(json); // JsonValue
});
```

Expand Down Expand Up @@ -316,29 +316,3 @@ const func: Func = () => {
```

So, the only reasonable type for `Object.keys` to return is `Array<string>`.

### Generics for `JSON.parse`, `Response.json` etc

A common request is for `ts-reset` to add type arguments to functions like `JSON.parse`:

```ts
const str = JSON.parse<string>('"hello"');

console.log(str); // string
```

This appears to improve the DX by giving you autocomplete on the thing that gets returned from `JSON.parse`.

However, we argue that this is a lie to the compiler and so, unsafe.

`JSON.parse` and `fetch` represent _validation boundaries_ - places where unknown data can enter your application code.

If you _really_ know what data is coming back from a `JSON.parse`, then an `as` assertion feels like the right call:

```ts
const str = JSON.parse('"hello"') as string;

console.log(str); // string
```

This provides the types you intend and also signals to the developer that this is _slightly_ unsafe.
4 changes: 3 additions & 1 deletion src/entrypoints/fetch.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/// <reference path="json.d.ts" />

interface Body {
json(): Promise<unknown>;
json(): Promise<TSReset.JsonValue>;
}
18 changes: 15 additions & 3 deletions src/entrypoints/json-parse.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
/// <reference path="json.d.ts" />

interface JSON {
/**
* Converts a JavaScript Object Notation (JSON) string into an object.
* @param text A valid JSON string.
*/
parse(text: string): TSReset.JsonValue;

/**
* Converts a JavaScript Object Notation (JSON) string into an object.
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of the object.
* If a member contains nested objects, the nested objects are transformed before the parent object is.
*/
parse(
parse<A = unknown>(
text: string,
reviver?: (this: any, key: string, value: any) => any,
): unknown;
reviver: <K extends string>(
this: TSReset.JsonHolder<K, A>,
key: K,
value: TSReset.JsonValueF<A>,
) => A,
): A;
}
47 changes: 47 additions & 0 deletions src/entrypoints/json-stringify.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// <reference path="json.d.ts" />

interface JSON {
/**
* Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified.
* @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
*/
stringify<A>(
value: A,
replacer?: (string | number)[] | null | undefined,
space?: string | number | undefined,
): TSReset.StringifyResult<TSReset.ToJson<A>>;

/**
* Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results.
* @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
*/
stringify<A>(
value: A,
replacer: (
this: TSReset.JsonComposite<A>,
key: string,
value: TSReset.ToJson<A>,
) => TSReset.JsonValueF<A>,
space?: string | number | undefined,
): string;

/**
* Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results.
* @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
*/
stringify<A>(
value: A,
replacer: (
this: TSReset.JsonComposite<A>,
key: string,
value: TSReset.ToJson<A>,
) => TSReset.JsonValueF<A> | undefined,
space?: string | number | undefined,
): string | undefined;
}
27 changes: 27 additions & 0 deletions src/entrypoints/json.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
declare namespace TSReset {
type JsonPrimitive = string | number | boolean | null;

type JsonComposite<A> = Record<string, A> | A[];

type JsonValueF<A> = JsonPrimitive | JsonComposite<A>;

type JsonValue = JsonPrimitive | JsonObject | JsonValue[];

type JsonObject = { [key: string]: JsonValue };

type JsonHolder<K extends string, A> = Record<K, JsonValueF<A>>;

type ToJson<A> = A extends { toJSON(...args: any): infer T } ? T : A;

type SomeExtends<A, B> = A extends B ? undefined : never;

type SomeFunction = (...args: any) => any;

type SomeConstructor = new (...args: any) => any;

type UndefinedDomain = symbol | SomeFunction | SomeConstructor | undefined;

type StringifyValue<A> = A extends UndefinedDomain ? undefined : string;

type StringifyResult<A> = StringifyValue<A> | SomeExtends<UndefinedDomain, A>;
}
2 changes: 1 addition & 1 deletion src/tests/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { doNotExecute, Equal, Expect } from "./utils";
doNotExecute(async () => {
const result = await fetch("/").then((res) => res.json());

type tests = [Expect<Equal<typeof result, unknown>>];
type tests = [Expect<Equal<typeof result, TSReset.JsonValue>>];
});

doNotExecute(async () => {
Expand Down
75 changes: 72 additions & 3 deletions src/tests/json-parse.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,83 @@
import { isNumber, isString, isEntries } from "./type-guards";
import { doNotExecute, Equal, Expect } from "./utils";

const isNumberStringEntries = isEntries(isNumber, isString);

doNotExecute(() => {
const result = JSON.parse("{}");

type tests = [Expect<Equal<typeof result, TSReset.JsonValue>>];
});

doNotExecute(() => {
const result = JSON.parse('{"p": 5}', (key, value) =>
typeof value === "number" ? value * 2 : value,
);

type tests = [Expect<Equal<typeof result, unknown>>];
});

doNotExecute(() => {
// Make tests fail when someone tries to PR JSON.parse<T>
const result = JSON.parse<TSReset.JsonValue>('{"p": 5}', (key, value) =>
typeof value === "number" ? value * 2 : value,
);

type tests = [Expect<Equal<typeof result, TSReset.JsonValue>>];
});

doNotExecute(() => {
const result = JSON.parse('[[1,"one"],[2,"two"],[3,"three"]]', (key, value) =>
// @ts-expect-error
key === "" ? new Map(value) : value,
);
});

doNotExecute(() => {
const result = JSON.parse('[[1,"one"],[2,"two"],[3,"three"]]', (key, value) =>
key === "" && isNumberStringEntries(value) ? new Map(value) : value,
);

type tests = [Expect<Equal<typeof result, unknown>>];
});

doNotExecute(() => {
type JsonValueWithMap = TSReset.JsonValue | Map<number, string>;

const result = JSON.parse<JsonValueWithMap>(
'[[1,"one"],[2,"two"],[3,"three"]]',
(key, value) =>
// @ts-expect-error
key === "" && isNumberStringEntries(value) ? new Map(value) : value,
);
});

doNotExecute(() => {
type JsonValueWithMap = TSReset.JsonValueF<
TSReset.JsonValue | Map<number, string>
>;

const result = JSON.parse<JsonValueWithMap>(
'[[1,"one"],[2,"two"],[3,"three"]]',
(key, value) =>
// @ts-expect-error
key === "" && isNumberStringEntries(value) ? new Map(value) : value,
);
});

doNotExecute(() => {
type JsonValueWithMap =
| TSReset.JsonPrimitive
| Map<number, string>
| JsonObjectWithMap
| JsonValueWithMap[];

type JsonObjectWithMap = { [key: string]: JsonValueWithMap };

const result = JSON.parse<JsonValueWithMap>(
'[[1,"one"],[2,"two"],[3,"three"]]',
(key, value) =>
key === "" && isNumberStringEntries(value) ? new Map(value) : value,
);

// @ts-expect-error
const result = JSON.parse<string>("{}");
type tests = [Expect<Equal<typeof result, JsonValueWithMap>>];
});
Loading