Skip to content

Commit 07d192d

Browse files
committed
Make fieldsUnion nicer to use
1 parent 223fc16 commit 07d192d

File tree

6 files changed

+433
-138
lines changed

6 files changed

+433
-138
lines changed

CHANGELOG.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,77 @@
11
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.
22

3+
### Version 12.0.0 (unreleased)
4+
5+
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.
6+
7+
- 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.
8+
9+
- 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.
10+
11+
- 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).
12+
13+
Here’s an example of how to upgrade:
14+
15+
```ts
16+
fieldsUnion("tag", {
17+
Circle: fieldsAuto({
18+
tag: () => "Circle" as const,
19+
radius: number,
20+
}),
21+
Rectangle: fields((field) => ({
22+
tag: "Rectangle" as const,
23+
width: field("width_px", number),
24+
height: field("height_px", number),
25+
})),
26+
});
27+
```
28+
29+
After:
30+
31+
```ts
32+
fieldsUnion("tag", [
33+
{
34+
tag: tag("Circle"),
35+
radius: number,
36+
},
37+
{
38+
tag: tag("Rectangle"),
39+
width: field(number, { renameFrom: "width_px" }),
40+
height: field(number, { renameFrom: "height_px" }),
41+
},
42+
]);
43+
```
44+
45+
And here’s an example of how to upgrade a case where the JSON and TypeScript names are different:
46+
47+
```ts
48+
fieldsUnion("type", {
49+
circle: fieldsAuto({
50+
tag: () => "Circle" as const,
51+
radius: number,
52+
}),
53+
square: fieldsAuto({
54+
tag: () => "Square" as const,
55+
size: number,
56+
}),
57+
});
58+
```
59+
60+
After:
61+
62+
```ts
63+
fieldsUnion("tag", [
64+
{
65+
tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }),
66+
radius: number,
67+
},
68+
{
69+
tag: tag("Square", { renameTagFrom: "square", renameFieldFrom: "type" }),
70+
size: number,
71+
},
72+
]);
73+
```
74+
375
### Version 11.0.0 (2023-10-21)
476

577
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).

README.md

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -219,18 +219,25 @@ Here’s a summary of all decoders (with slightly simplified type annotations):
219219
<tr>
220220
<th><a href="#fieldsunion">fieldsUnion</a></th>
221221
<td><pre>(
222-
key: string,
223-
mapping: {
224-
key1: Decoder&lt;T1&gt;,
225-
key2: Decoder&lt;T2&gt;,
226-
keyN: Decoder&lt;TN&gt;
227-
}
222+
decodedCommonField: string,
223+
variants: Array&lt;
224+
Parameters&lt;typeof fieldsAuto&gt;[0]
225+
&gt;,
228226
) =&gt;
229227
Decoder&lt;T1 | T2 | TN&gt;</pre></td>
230228
<td>object</td>
231229
<td><code>T1 | T2 | TN</code></td>
232230
</tr>
233231
<tr>
232+
<th><a href="#tag">tag</a></th>
233+
<td><pre>(
234+
decoded: "string literal",
235+
options?: Options,
236+
): Field&lt;"string literal", Meta&gt;</pre></td>
237+
<td>string</td>
238+
<td><code>"string literal"</code></td>
239+
</tr>
240+
<tr>
234241
<th><a href="#tuple">tuple</a></th>
235242
<td><pre>(mapping: [
236243
Decoder&lt;T1&gt;,
@@ -449,6 +456,7 @@ type Field<Decoded, Meta extends FieldMeta> = Meta & {
449456
type FieldMeta = {
450457
renameFrom?: string | undefined;
451458
optional?: boolean | undefined;
459+
tag?: { decoded: string; encoded: string } | undefined;
452460
};
453461

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

486494
> **Warning**
487-
> 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.
495+
> 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.
488496
489497
### field
490498

491499
```ts
492-
function field<Decoded, const Meta extends FieldMeta>(
500+
function field<Decoded, const Meta extends Omit<FieldMeta, "tag">>(
493501
decoder: Decoder<Decoded>,
494502
meta: Meta,
495503
): Field<Decoded, Meta>;
@@ -501,6 +509,7 @@ type Field<Decoded, Meta extends FieldMeta> = Meta & {
501509
type FieldMeta = {
502510
renameFrom?: string | undefined;
503511
optional?: boolean | undefined;
512+
tag?: { decoded: string; encoded: string } | undefined;
504513
};
505514
```
506515

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

513522
Use it with [fieldsAuto](#fieldsAuto).
514523

524+
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">`.)
525+
515526
Here’s an example illustrating the difference between `field(string, { optional: true })` and `undefinedOr(string)`:
516527

517528
```ts
@@ -581,23 +592,34 @@ type Example = {
581592
### fieldsUnion
582593
583594
```ts
584-
type Values<T> = T[keyof T];
595+
function fieldsUnion<
596+
const DecodedCommonField extends keyof Variants[number],
597+
Variants extends readonly [
598+
Variant<DecodedCommonField>,
599+
...ReadonlyArray<Variant<DecodedCommonField>>,
600+
],
601+
>(
602+
decodedCommonField: DecodedCommonField,
603+
variants: Variants,
604+
{ exact = "allow extra" }: { exact?: "allow extra" | "throw" } = {},
605+
): Decoder<InferFieldsUnion<Variants[number]>>;
585606
586-
function fieldsUnion<T extends Record<string, Decoder<unknown>>>(
587-
key: string,
588-
mapping: T,
589-
): Decoder<
590-
Values<{ [P in keyof T]: T[P] extends Decoder<infer U, infer _> ? U : never }>
591-
>;
607+
type Variant<DecodedCommonField extends string> = Record<
608+
DecodedCommonField,
609+
Field<any, { tag: { decoded: string; encoded: string } }>
610+
> &
611+
Record<string, Decoder<any> | Field<any, FieldMeta>>;
612+
613+
type InferFieldsUnion<MappingsUnion extends FieldsMapping> = magic;
614+
615+
// See `fieldsAuto` for the definitions of `Field`, `FieldMeta` and `FieldsMapping`.
592616
```
593617
594618
Decodes JSON objects with a common string field (that tells them apart) into a TypeScript union type.
595619

596-
The `key` is the name of the common string field.
620+
The `decodedCommonField` is the name of the common string field.
597621

598-
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.
599-
600-
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`.
622+
`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.
601623

602624
```ts
603625
type Shape =
@@ -606,11 +628,11 @@ type Shape =
606628

607629
const shapeDecoder = fieldsUnion("tag", {
608630
Circle: fieldsAuto({
609-
tag: () => "Circle" as const,
631+
tag: tag("Circle"),
610632
radius: number,
611633
}),
612634
Rectangle: fieldsAuto({
613-
tag: () => "Rectangle" as const,
635+
tag: tag("Rectangle"),
614636
width: field(number, { renameFrom: "width_px" }),
615637
height: field(number, { renameFrom: "height_px" }),
616638
}),
@@ -619,6 +641,39 @@ const shapeDecoder = fieldsUnion("tag", {
619641

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

644+
### tag
645+
646+
```ts
647+
export function tag<
648+
const Decoded extends string,
649+
const Encoded extends string,
650+
const EncodedFieldName extends string,
651+
>(
652+
decoded: Decoded,
653+
{
654+
renameTagFrom = decoded,
655+
renameFieldFrom,
656+
}: {
657+
renameTagFrom?: Encoded;
658+
renameFieldFrom?: EncodedFieldName;
659+
} = {},
660+
): Field<
661+
Decoded,
662+
{
663+
renameFrom: EncodedFieldName | undefined;
664+
tag: { decoded: string; encoded: string };
665+
}
666+
>;
667+
```
668+
669+
Used with [fieldsUnion](#fieldsunion), once for each variant of the union.
670+
671+
`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.
672+
673+
`tag("MyTag", { renameTagFrom: "my_tag" })` returns a `Field` with a decoder that requires the input `"my_tag"` but returns `"MyTag"`.
674+
675+
For `renameFieldFrom`, see [fieldsUnion](#fieldsunion).
676+
622677
### tuple
623678

624679
```ts

examples/renaming-union-field.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import { expectType, TypeEqual } from "ts-expect";
22
import { expect, test } from "vitest";
33

4-
import { fieldsAuto, fieldsUnion, number } from "../";
4+
import { fieldsUnion, number, tag } from "../";
55

66
test("using different tags in JSON and in TypeScript", () => {
7-
// There’s nothing stopping you from using different keys and values in JSON
8-
// and TypeScript. For example, `"type": "circle"` → `tag: "Circle"`.
9-
const decoder = fieldsUnion("type", {
10-
circle: fieldsAuto({
11-
tag: () => "Circle" as const,
7+
// Here’s how to use different keys and values in JSON and TypeScript.
8+
// For example, `"type": "circle"` → `tag: "Circle"`.
9+
const decoder = fieldsUnion("tag", [
10+
{
11+
tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }),
1212
radius: number,
13-
}),
14-
square: fieldsAuto({
15-
tag: () => "Square" as const,
13+
},
14+
{
15+
tag: tag("Square", { renameTagFrom: "square", renameFieldFrom: "type" }),
1616
size: number,
17-
}),
18-
});
17+
},
18+
]);
1919

2020
type InferredType = ReturnType<typeof decoder>;
2121
type ExpectedType =

examples/type-inference.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
number,
1717
string,
1818
stringUnion,
19+
tag,
1920
undefinedOr,
2021
} from "..";
2122

@@ -146,17 +147,17 @@ test("making a type from a decoder – unions", () => {
146147
// Let’s say we need to support two types of users – anonymous and registered
147148
// ones. This is where `fieldsUnion` shines! It’s both easier to use and gives
148149
// a better inferred type!
149-
const userDecoder1 = fieldsUnion("type", {
150-
anonymous: fieldsAuto({
151-
type: () => "anonymous" as const,
150+
const userDecoder1 = fieldsUnion("type", [
151+
{
152+
type: tag("anonymous"),
152153
sessionId: number,
153-
}),
154-
registered: fieldsAuto({
155-
type: () => "registered" as const,
154+
},
155+
{
156+
type: tag("registered"),
156157
id: number,
157158
name: string,
158-
}),
159-
});
159+
},
160+
]);
160161
type InferredType1 = ReturnType<typeof userDecoder1>;
161162
type ExpectedType1 =
162163
| { type: "anonymous"; sessionId: number }

0 commit comments

Comments
 (0)