Skip to content

Commit

Permalink
Make fieldsUnion nicer to use (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
lydell authored Oct 22, 2023
1 parent 223fc16 commit e200129
Show file tree
Hide file tree
Showing 6 changed files with 434 additions and 138 deletions.
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,77 @@
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 12.0.0 (unreleased)

This release changes how `fieldsUnion` works. The new way should be easier to use, and it looks more similar to the type definition of a tagged union.

- Changed: The first argument to `fieldsUnion` is no longer the common field name used in the JSON, but the common field name used in TypeScript. This doesn’t matter if you use the same common field name in both JSON and TypeScript. But if you did use different names – don’t worry, you’ll get TypeScript errors so you won’t forget to update something.

- Changed: The second argument to `fieldsUnion` is now an array of objects, instead of an object with decoders. The objects in the array are “`fieldsAuto` objects” – they fit when passed to `fieldsAuto` as well. All of those objects must have the first argument to `fieldsUnion` as a key, and use the new `tag` function on that key.

- Added: The `tag` function. Used with `fieldsUnion`, once for each variant of the union. `tag("MyTag")` returns a `Field` with a decoder that requires the input `"MyTag"` and returns `"MyTag"`. The metadata of the `Field` also advertises that the tag value is `"MyTag"`, which `fieldsUnion` uses to know what to do. The `tag` function also lets you use a different common field in JSON than in TypeScript (similar to the `field` function for other fields).

Here’s an example of how to upgrade:

```ts
fieldsUnion("tag", {
Circle: fieldsAuto({
tag: () => "Circle" as const,
radius: number,
}),
Rectangle: fields((field) => ({
tag: "Rectangle" as const,
width: field("width_px", number),
height: field("height_px", number),
})),
});
```

After:

```ts
fieldsUnion("tag", [
{
tag: tag("Circle"),
radius: number,
},
{
tag: tag("Rectangle"),
width: field(number, { renameFrom: "width_px" }),
height: field(number, { renameFrom: "height_px" }),
},
]);
```

And here’s an example of how to upgrade a case where the JSON and TypeScript names are different:

```ts
fieldsUnion("type", {
circle: fieldsAuto({
tag: () => "Circle" as const,
radius: number,
}),
square: fieldsAuto({
tag: () => "Square" as const,
size: number,
}),
});
```

After:

```ts
fieldsUnion("tag", [
{
tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }),
radius: number,
},
{
tag: tag("Square", { renameTagFrom: "square", renameFieldFrom: "type" }),
size: number,
},
]);
```

### Version 11.0.0 (2023-10-21)

This release deprecates `fields`, and makes `fieldsAuto` more powerful so that it can do most of what only `fields` could before. Removing `fields` unlocks further changes that will come in future releases. It’s also nice to have just one way of decoding objects (`fieldsAuto`), instead of having two. Finally, the changes to `fieldsAuto` gets rid of a flawed design choice which solves several reported bugs: [#22](https://github.com/lydell/tiny-decoders/issues/22) and [#24](https://github.com/lydell/tiny-decoders/issues/24).
Expand Down
97 changes: 76 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,25 @@ Here’s a summary of all decoders (with slightly simplified type annotations):
<tr>
<th><a href="#fieldsunion">fieldsUnion</a></th>
<td><pre>(
key: string,
mapping: {
key1: Decoder&lt;T1&gt;,
key2: Decoder&lt;T2&gt;,
keyN: Decoder&lt;TN&gt;
}
decodedCommonField: string,
variants: Array&lt;
Parameters&lt;typeof fieldsAuto&gt;[0]
&gt;,
) =&gt;
Decoder&lt;T1 | T2 | TN&gt;</pre></td>
<td>object</td>
<td><code>T1 | T2 | TN</code></td>
</tr>
<tr>
<th><a href="#tag">tag</a></th>
<td><pre>(
decoded: "string literal",
options?: Options,
): Field&lt;"string literal", Meta&gt;</pre></td>
<td>string</td>
<td><code>"string literal"</code></td>
</tr>
<tr>
<th><a href="#tuple">tuple</a></th>
<td><pre>(mapping: [
Decoder&lt;T1&gt;,
Expand Down Expand Up @@ -449,6 +456,7 @@ type Field<Decoded, Meta extends FieldMeta> = Meta & {
type FieldMeta = {
renameFrom?: string | undefined;
optional?: boolean | undefined;
tag?: { decoded: string; encoded: string } | undefined;
};

type InferFields<Mapping extends FieldsMapping> = magic;
Expand Down Expand Up @@ -484,12 +492,12 @@ The `exact` option let’s you choose between ignoring extraneous data and makin
See also the [Extra fields](examples/extra-fields.test.ts) example.

> **Warning**
> Temporary behavior: If a field is missing and _not_ marked as optional, `fieldsAuto` still _tries_ the decoder at the field (passing `undefined` to it). If the decoder succeeds (because it allows `undefined` or succeeds for any input), that value is used. If it fails, the regular “missing field” error is thrown. This means that `fieldsAuto({ name: undefinedOr(string) })` successfully produces `{ name: undefined }` if given `{}` as input. It is supposed to fail in that case (because a required field is missing), but temporarily it does not fail. This is to support how `fieldsUnion` is used currently. When `fieldsUnion` is updated to a new API in an upcoming version of tiny-decoders, this temporary behavior in `fieldsAuto` will be removed.
> Temporary behavior: If a field is missing and _not_ marked as optional, `fieldsAuto` still _tries_ the decoder at the field (passing `undefined` to it). If the decoder succeeds (because it allows `undefined` or succeeds for any input), that value is used. If it fails, the regular “missing field” error is thrown. This means that `fieldsAuto({ name: undefinedOr(string) })` successfully produces `{ name: undefined }` if given `{}` as input. It is supposed to fail in that case (because a required field is missing), but temporarily it does not fail. This is to support how a previous version of `fieldsUnion` was used. Now `fieldsUnion` has been updated to a new API, so this temporary behavior in `fieldsAuto` will be removed in an upcoming version of tiny-decoders.
### field

```ts
function field<Decoded, const Meta extends FieldMeta>(
function field<Decoded, const Meta extends Omit<FieldMeta, "tag">>(
decoder: Decoder<Decoded>,
meta: Meta,
): Field<Decoded, Meta>;
Expand All @@ -501,6 +509,7 @@ type Field<Decoded, Meta extends FieldMeta> = Meta & {
type FieldMeta = {
renameFrom?: string | undefined;
optional?: boolean | undefined;
tag?: { decoded: string; encoded: string } | undefined;
};
```

Expand All @@ -512,6 +521,8 @@ This function takes a decoder and lets you:

Use it with [fieldsAuto](#fieldsAuto).

The `tag` thing is handled by the [tag](#tag) function. It’s not something you’ll set manually using `field`. (That’s why the type annotation says `Omit<FieldMeta, "tag">`.)

Here’s an example illustrating the difference between `field(string, { optional: true })` and `undefinedOr(string)`:

```ts
Expand Down Expand Up @@ -581,23 +592,34 @@ type Example = {
### fieldsUnion
```ts
type Values<T> = T[keyof T];
function fieldsUnion<
const DecodedCommonField extends keyof Variants[number],
Variants extends readonly [
Variant<DecodedCommonField>,
...ReadonlyArray<Variant<DecodedCommonField>>,
],
>(
decodedCommonField: DecodedCommonField,
variants: Variants,
{ exact = "allow extra" }: { exact?: "allow extra" | "throw" } = {},
): Decoder<InferFieldsUnion<Variants[number]>>;
function fieldsUnion<T extends Record<string, Decoder<unknown>>>(
key: string,
mapping: T,
): Decoder<
Values<{ [P in keyof T]: T[P] extends Decoder<infer U, infer _> ? U : never }>
>;
type Variant<DecodedCommonField extends string> = Record<
DecodedCommonField,
Field<any, { tag: { decoded: string; encoded: string } }>
> &
Record<string, Decoder<any> | Field<any, FieldMeta>>;
type InferFieldsUnion<MappingsUnion extends FieldsMapping> = magic;
// See `fieldsAuto` for the definitions of `Field`, `FieldMeta` and `FieldsMapping`.
```
Decodes JSON objects with a common string field (that tells them apart) into a TypeScript union type.

The `key` is the name of the common string field.
The `decodedCommonField` is the name of the common string field.

The `mapping` is an object where the keys are the strings of the `key` field and the values are decoders. The decoders are usually `fields` or `fieldsAuto`. The keys must be strings (not numbers) and you must provide at least one key.

You _can_ use [fields](#fields) to accomplish the same thing, but it’s easier with `fieldsUnion`. You also get better error messages and type inference with `fieldsUnion`.
`variants` is an array of objects. Those objects are “`fieldsAuto` objects” – they fit when passed to `fieldsAuto` as well. All of those objects must have `decodedCommonField` as a key, and use the [tag](#tag) function on that key.

```ts
type Shape =
Expand All @@ -606,11 +628,11 @@ type Shape =

const shapeDecoder = fieldsUnion("tag", {
Circle: fieldsAuto({
tag: () => "Circle" as const,
tag: tag("Circle"),
radius: number,
}),
Rectangle: fieldsAuto({
tag: () => "Rectangle" as const,
tag: tag("Rectangle"),
width: field(number, { renameFrom: "width_px" }),
height: field(number, { renameFrom: "height_px" }),
}),
Expand All @@ -619,6 +641,39 @@ const shapeDecoder = fieldsUnion("tag", {

See also the [renaming union field example](examples/renaming-union-field.test.ts).

### tag

```ts
export function tag<
const Decoded extends string,
const Encoded extends string,
const EncodedFieldName extends string,
>(
decoded: Decoded,
{
renameTagFrom = decoded,
renameFieldFrom,
}: {
renameTagFrom?: Encoded;
renameFieldFrom?: EncodedFieldName;
} = {},
): Field<
Decoded,
{
renameFrom: EncodedFieldName | undefined;
tag: { decoded: string; encoded: string };
}
>;
```

Used with [fieldsUnion](#fieldsunion), once for each variant of the union.

`tag("MyTag")` returns a `Field` with a decoder that requires the input `"MyTag"` and returns `"MyTag"`. The metadata of the `Field` also advertises that the tag value is `"MyTag"`, which `fieldsUnion` uses to know what to do.

`tag("MyTag", { renameTagFrom: "my_tag" })` returns a `Field` with a decoder that requires the input `"my_tag"` but returns `"MyTag"`.

For `renameFieldFrom`, see [fieldsUnion](#fieldsunion).

### tuple

```ts
Expand Down
22 changes: 11 additions & 11 deletions examples/renaming-union-field.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { expectType, TypeEqual } from "ts-expect";
import { expect, test } from "vitest";

import { fieldsAuto, fieldsUnion, number } from "../";
import { fieldsUnion, number, tag } from "../";

test("using different tags in JSON and in TypeScript", () => {
// There’s nothing stopping you from using different keys and values in JSON
// and TypeScript. For example, `"type": "circle"` → `tag: "Circle"`.
const decoder = fieldsUnion("type", {
circle: fieldsAuto({
tag: () => "Circle" as const,
// Here’s how to use different keys and values in JSON and TypeScript.
// For example, `"type": "circle"` → `tag: "Circle"`.
const decoder = fieldsUnion("tag", [
{
tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }),
radius: number,
}),
square: fieldsAuto({
tag: () => "Square" as const,
},
{
tag: tag("Square", { renameTagFrom: "square", renameFieldFrom: "type" }),
size: number,
}),
});
},
]);

type InferredType = ReturnType<typeof decoder>;
type ExpectedType =
Expand Down
17 changes: 9 additions & 8 deletions examples/type-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
number,
string,
stringUnion,
tag,
undefinedOr,
} from "..";

Expand Down Expand Up @@ -146,17 +147,17 @@ test("making a type from a decoder – unions", () => {
// Let’s say we need to support two types of users – anonymous and registered
// ones. This is where `fieldsUnion` shines! It’s both easier to use and gives
// a better inferred type!
const userDecoder1 = fieldsUnion("type", {
anonymous: fieldsAuto({
type: () => "anonymous" as const,
const userDecoder1 = fieldsUnion("type", [
{
type: tag("anonymous"),
sessionId: number,
}),
registered: fieldsAuto({
type: () => "registered" as const,
},
{
type: tag("registered"),
id: number,
name: string,
}),
});
},
]);
type InferredType1 = ReturnType<typeof userDecoder1>;
type ExpectedType1 =
| { type: "anonymous"; sessionId: number }
Expand Down
Loading

0 comments on commit e200129

Please sign in to comment.