From e200129be3dc2449a9aab95517803216d80ed2c2 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 22 Oct 2023 16:21:13 +0200 Subject: [PATCH] Make fieldsUnion nicer to use (#32) --- CHANGELOG.md | 72 +++++++++ README.md | 97 +++++++++--- examples/renaming-union-field.test.ts | 22 +-- examples/type-inference.test.ts | 17 ++- index.ts | 203 +++++++++++++++++++++----- tests/decoders.test.ts | 161 ++++++++++++-------- 6 files changed, 434 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc6ab8..6c7ca2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index ff580bc..9912022 100644 --- a/README.md +++ b/README.md @@ -219,18 +219,25 @@ Here’s a summary of all decoders (with slightly simplified type annotations): fieldsUnion
(
-  key: string,
-  mapping: {
-    key1: Decoder<T1>,
-    key2: Decoder<T2>,
-    keyN: Decoder<TN>
-  }
+  decodedCommonField: string,
+  variants: Array<
+    Parameters<typeof fieldsAuto>[0]
+  >,
 ) =>
   Decoder<T1 | T2 | TN>
object T1 | T2 | TN +tag +
(
+  decoded: "string literal",
+  options?: Options,
+): Field<"string literal", Meta>
+string +"string literal" + + tuple
(mapping: [
   Decoder<T1>,
@@ -449,6 +456,7 @@ type Field = Meta & {
 type FieldMeta = {
   renameFrom?: string | undefined;
   optional?: boolean | undefined;
+  tag?: { decoded: string; encoded: string } | undefined;
 };
 
 type InferFields = magic;
@@ -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(
+function field>(
   decoder: Decoder,
   meta: Meta,
 ): Field;
@@ -501,6 +509,7 @@ type Field = Meta & {
 type FieldMeta = {
   renameFrom?: string | undefined;
   optional?: boolean | undefined;
+  tag?: { decoded: string; encoded: string } | undefined;
 };
 ```
 
@@ -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`.)
+
 Here’s an example illustrating the difference between `field(string, { optional: true })` and `undefinedOr(string)`:
 
 ```ts
@@ -581,23 +592,34 @@ type Example = {
 ### fieldsUnion
 
 ```ts
-type Values = T[keyof T];
+function fieldsUnion<
+  const DecodedCommonField extends keyof Variants[number],
+  Variants extends readonly [
+    Variant,
+    ...ReadonlyArray>,
+  ],
+>(
+  decodedCommonField: DecodedCommonField,
+  variants: Variants,
+  { exact = "allow extra" }: { exact?: "allow extra" | "throw" } = {},
+): Decoder>;
 
-function fieldsUnion>>(
-  key: string,
-  mapping: T,
-): Decoder<
-  Values<{ [P in keyof T]: T[P] extends Decoder ? U : never }>
->;
+type Variant = Record<
+  DecodedCommonField,
+  Field
+> &
+  Record | Field>;
+
+type InferFieldsUnion = 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 =
@@ -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" }),
   }),
@@ -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
diff --git a/examples/renaming-union-field.test.ts b/examples/renaming-union-field.test.ts
index eecd390..5d65ac5 100644
--- a/examples/renaming-union-field.test.ts
+++ b/examples/renaming-union-field.test.ts
@@ -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;
   type ExpectedType =
diff --git a/examples/type-inference.test.ts b/examples/type-inference.test.ts
index 7c38d30..966a6f2 100644
--- a/examples/type-inference.test.ts
+++ b/examples/type-inference.test.ts
@@ -16,6 +16,7 @@ import {
   number,
   string,
   stringUnion,
+  tag,
   undefinedOr,
 } from "..";
 
@@ -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;
   type ExpectedType1 =
     | { type: "anonymous"; sessionId: number }
diff --git a/index.ts b/index.ts
index 0e78e1d..e53b819 100644
--- a/index.ts
+++ b/index.ts
@@ -166,6 +166,7 @@ type Field = Meta & {
 type FieldMeta = {
   renameFrom?: string | undefined;
   optional?: boolean | undefined;
+  tag?: { decoded: string; encoded: string } | undefined;
 };
 
 type FieldsMapping = Record | Field>;
@@ -259,7 +260,7 @@ export function fieldsAuto(
   };
 }
 
-export function field(
+export function field>(
   decoder: Decoder,
   meta: Meta,
 ): Field {
@@ -269,46 +270,158 @@ export function field(
   };
 }
 
-type Values = T[keyof T];
-
-export function fieldsUnion>>(
-  key: string,
-  mapping: keyof T extends string
-    ? keyof T extends never
-      ? "fieldsUnion must have at least one member"
-      : T
-    : {
-        [P in keyof T]: P extends number
-          ? "fieldsUnion keys must be strings, not numbers"
-          : T[P];
-      },
-): Decoder<
-  Expand<
-    Values<{
-      [P in keyof T]: T[P] extends Decoder ? U : never;
-    }>
-  >
-> {
-  // eslint-disable-next-line prefer-arrow-callback
-  return fields(function fieldsUnionFields(field_, object) {
-    const tag = field_(key, string);
-    if (Object.prototype.hasOwnProperty.call(mapping, tag)) {
-      const decoder = (mapping as T)[tag];
-      return decoder(object);
+type InferFieldsUnion =
+  MappingsUnion extends any ? InferFields : never;
+
+type Variant = Record<
+  DecodedCommonField,
+  Field
+> &
+  Record | Field>;
+
+export function fieldsUnion<
+  const DecodedCommonField extends keyof Variants[number],
+  Variants extends readonly [
+    Variant,
+    ...ReadonlyArray>,
+  ],
+>(
+  decodedCommonField: DecodedCommonField,
+  variants: Variants,
+  { exact = "allow extra" }: { exact?: "allow extra" | "throw" } = {},
+): Decoder> {
+  if (decodedCommonField === "__proto__") {
+    throw new Error("fieldsUnion: commonField cannot be __proto__");
+  }
+
+  const decoderMap = new Map>(); // encodedName -> decoder
+
+  let maybeEncodedCommonField: number | string | symbol | undefined = undefined;
+
+  for (const [index, variant] of variants.entries()) {
+    const field_: Field<
+      any,
+      FieldMeta & { tag: { decoded: string; encoded: string } }
+    > = variant[decodedCommonField];
+    const { renameFrom: encodedFieldName = decodedCommonField } = field_;
+    if (maybeEncodedCommonField === undefined) {
+      maybeEncodedCommonField = encodedFieldName;
+    } else if (maybeEncodedCommonField !== encodedFieldName) {
+      throw new Error(
+        `Codec.fieldsUnion: Variant at index ${index}: Key ${JSON.stringify(
+          decodedCommonField,
+        )}: Got a different encoded field name (${JSON.stringify(
+          encodedFieldName,
+        )}) than before (${JSON.stringify(maybeEncodedCommonField)}).`,
+      );
     }
-    throw new DecoderError({
-      tag: "unknown fieldsUnion tag",
-      knownTags: Object.keys(mapping),
-      got: tag,
-      key,
-    });
-  }) as Decoder<
-    Expand<
-      Values<{
-        [P in keyof T]: T[P] extends Decoder ? U : never;
-      }>
-    >
-  >;
+    const fullDecoder = fieldsAuto(variant, { exact });
+    decoderMap.set(field_.tag.encoded, fullDecoder);
+  }
+
+  if (typeof maybeEncodedCommonField !== "string") {
+    throw new Error(
+      `Codec.fieldsUnion: Got unusable encoded common field: ${repr(
+        maybeEncodedCommonField,
+      )}`,
+    );
+  }
+
+  const encodedCommonField = maybeEncodedCommonField;
+
+  return function fieldsUnionDecoder(
+    value: unknown,
+  ): InferFieldsUnion {
+    const encodedName = fieldsAuto({ [encodedCommonField]: string })(value)[
+      encodedCommonField
+    ];
+    const decoder = decoderMap.get(encodedName);
+    if (decoder === undefined) {
+      throw new DecoderError({
+        tag: "unknown fieldsUnion tag",
+        knownTags: Array.from(decoderMap.keys()),
+        got: encodedName,
+        key: encodedCommonField,
+      });
+    }
+    return decoder(value) as InferFieldsUnion;
+  };
+}
+
+export function tag(
+  decoded: Decoded,
+): Field;
+
+export function tag(
+  decoded: Decoded,
+  options: {
+    renameTagFrom: Encoded;
+  },
+): Field;
+
+export function tag<
+  const Decoded extends string,
+  const EncodedFieldName extends string,
+>(
+  decoded: Decoded,
+  options: {
+    renameFieldFrom: EncodedFieldName;
+  },
+): Field<
+  Decoded,
+  { renameFrom: EncodedFieldName; tag: { decoded: string; encoded: string } }
+>;
+
+export function tag<
+  const Decoded extends string,
+  const Encoded extends string,
+  const EncodedFieldName extends string,
+>(
+  decoded: Decoded,
+  options: {
+    renameTagFrom: Encoded;
+    renameFieldFrom: EncodedFieldName;
+  },
+): Field<
+  Decoded,
+  { renameFrom: EncodedFieldName; tag: { decoded: string; encoded: string } }
+>;
+
+export function tag<
+  const Decoded extends string,
+  const Encoded extends string,
+  const EncodedFieldName extends string,
+>(
+  decoded: Decoded,
+  {
+    renameTagFrom: encoded = decoded as unknown as Encoded,
+    renameFieldFrom: encodedFieldName,
+  }: {
+    renameTagFrom?: Encoded;
+    renameFieldFrom?: EncodedFieldName;
+  } = {},
+): Field<
+  Decoded,
+  {
+    renameFrom: EncodedFieldName | undefined;
+    tag: { decoded: string; encoded: string };
+  }
+> {
+  return {
+    decoder: function tagDecoder(value: unknown): Decoded {
+      const str = string(value);
+      if (str !== encoded) {
+        throw new DecoderError({
+          tag: "wrong tag",
+          expected: encoded,
+          got: str,
+        });
+      }
+      return decoded;
+    },
+    renameFrom: encodedFieldName,
+    tag: { decoded, encoded },
+  };
 }
 
 export function tuple>(
@@ -518,6 +631,11 @@ export type DecoderErrorVariant =
       knownVariants: Array;
       got: string;
     }
+  | {
+      tag: "wrong tag";
+      expected: string;
+      got: string;
+    }
   | { tag: "array"; got: unknown }
   | { tag: "boolean"; got: unknown }
   | { tag: "number"; got: unknown }
@@ -586,6 +704,11 @@ function formatDecoderErrorVariant(
         variant.field,
       )}\nGot: ${formatGot(variant.got)}`;
 
+    case "wrong tag":
+      return `Expected this string: ${JSON.stringify(
+        variant.expected,
+      )}\nGot: ${formatGot(variant.got)}`;
+
     case "exact fields":
       return `Expected only these fields:${stringList(
         variant.knownFields,
diff --git a/tests/decoders.test.ts b/tests/decoders.test.ts
index 890a843..edc17a6 100644
--- a/tests/decoders.test.ts
+++ b/tests/decoders.test.ts
@@ -21,6 +21,7 @@ import {
   repr,
   string,
   stringUnion,
+  tag,
   tuple,
   undefinedOr,
 } from "..";
@@ -579,6 +580,10 @@ describe("fieldsAuto", () => {
       followers: field(undefinedOr(number), {}),
     });
 
+    // @ts-expect-error Argument of type '{ tag: { decoded: string; encoded: string; }; }' is not assignable to parameter of type 'Omit'.
+    //   Object literal may only specify known properties, and 'tag' does not exist in type 'Omit'.
+    field(string, { tag: { decoded: "A", encoded: "a" } });
+
     expectType<
       TypeEqual<
         Person,
@@ -820,17 +825,17 @@ describe("fieldsAuto", () => {
 describe("fieldsUnion", () => {
   test("basic", () => {
     type Shape = ReturnType;
-    const shapeDecoder = fieldsUnion("tag", {
-      Circle: fieldsAuto({
-        tag: () => "Circle" as const,
+    const shapeDecoder = fieldsUnion("tag", [
+      {
+        tag: tag("Circle"),
         radius: number,
-      }),
-      Rectangle: fields((field) => ({
-        tag: "Rectangle" as const,
-        width: field("width_px", number),
-        height: field("height_px", number),
-      })),
-    });
+      },
+      {
+        tag: tag("Rectangle"),
+        width: field(number, { renameFrom: "width_px" }),
+        height: field(number, { renameFrom: "height_px" }),
+      },
+    ]);
 
     expectType<
       TypeEqual<
@@ -848,10 +853,13 @@ describe("fieldsUnion", () => {
 
     expect(run(shapeDecoder, { tag: "Rectangle", radius: 5 }))
       .toMatchInlineSnapshot(`
-        At root["width_px"]:
-        Expected a number
-        Got: undefined
-      `);
+      At root:
+      Expected an object with a field called: "width_px"
+      Got: {
+        "tag": "Rectangle",
+        "radius": 5
+      }
+    `);
 
     expect(run(shapeDecoder, { tag: "Square", size: 5 }))
       .toMatchInlineSnapshot(`
@@ -862,7 +870,8 @@ describe("fieldsUnion", () => {
         Got: "Square"
       `);
 
-    expect(run(fieldsUnion("0", { a: () => 0 }), ["a"])).toMatchInlineSnapshot(`
+    expect(run(fieldsUnion("0", [{ "0": tag("a") }]), ["a"]))
+      .toMatchInlineSnapshot(`
       At root:
       Expected an object
       Got: [
@@ -871,55 +880,91 @@ describe("fieldsUnion", () => {
     `);
   });
 
-  test("edge case keys", () => {
-    const edgeCaseDecoder = fieldsUnion("tag", {
-      constructor: (x) => x,
-      // Specifying `__proto__` is safe here.
-      __proto__: (x) => x,
-    });
-    expect(edgeCaseDecoder({ tag: "constructor" })).toStrictEqual({
-      tag: "constructor",
-    });
-    // But `__proto__` won’t work, because it’s not an “own” property for some reason.
-    // I haven’t been able to forbid `__proto__` using TypeScript.
-    // Notice how "__proto__" isn’t even in the expected keys.
-    expect(run(edgeCaseDecoder, { tag: "__proto__" })).toMatchInlineSnapshot(`
-      At root["tag"]:
-      Expected one of these tags:
-        "constructor"
-      Got: "__proto__"
-    `);
-    expect(run(edgeCaseDecoder, { tag: "hasOwnProperty" }))
-      .toMatchInlineSnapshot(`
-        At root["tag"]:
-        Expected one of these tags:
-          "constructor"
-        Got: "hasOwnProperty"
-      `);
+  test("__proto__ is not allowed", () => {
+    expect(() =>
+      fieldsUnion("__proto__", [{ __proto__: tag("Test") }]),
+    ).toThrowErrorMatchingInlineSnapshot(
+      '"fieldsUnion: commonField cannot be __proto__"',
+    );
   });
 
   test("empty object is not allowed", () => {
-    // @ts-expect-error Argument of type '{}' is not assignable to parameter of type '"fieldsUnion must have at least one member"'.
-    const emptyDecoder = fieldsUnion("tag", {});
-    // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'Record>'.
-    fieldsUnion("tag", "fieldsUnion must have at least one member");
-    expectType, never>>(true);
-    expect(run(emptyDecoder, { tag: "test" })).toMatchInlineSnapshot(`
-      At root["tag"]:
-      Expected one of these tags: (none)
-      Got: "test"
+    expect(() =>
+      // @ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [Variant<"tag">, ...Variant<"tag">[]]'.
+      //   Source has 0 element(s) but target requires 1.
+      fieldsUnion("tag", []),
+    ).toThrowErrorMatchingInlineSnapshot(
+      '"Codec.fieldsUnion: Got unusable encoded common field: undefined"',
+    );
+  });
+
+  test("decodedCommonField mismatch", () => {
+    expect(() =>
+      // @ts-expect-error Property 'tag' is missing in type '{ type: Field<"Test", { tag: { decoded: string; encoded: string; }; }>; }' but required in type 'Record<"tag", Field>'.
+      fieldsUnion("tag", [{ type: tag("Test") }]),
+    ).toThrow();
+  });
+
+  test("one variant uses wrong decodedCommonField", () => {
+    expect(() =>
+      // @ts-expect-error Property 'tag' is missing in type '{ type: Field<"B", { tag: { decoded: string; encoded: string; }; }>; }' but required in type 'Record<"tag", Field>'.
+      fieldsUnion("tag", [{ tag: tag("A") }, { type: tag("B") }]),
+    ).toThrow();
+  });
+
+  test("decodedCommonField does not use the tag function", () => {
+    expect(() =>
+      // @ts-expect-error Type '(value: unknown) => string' is not assignable to type 'Field'.
+      fieldsUnion("tag", [{ tag: string }]),
+    ).toThrow();
+  });
+
+  test("encodedCommonField mismatch", () => {
+    expect(() =>
+      // TODO: This will be a TypeScript error in an upcoming version of tiny-decoders.
+      fieldsUnion("tag", [
+        { tag: tag("A") },
+        { tag: tag("B", { renameFieldFrom: "type" }) },
+      ]),
+    ).toThrowErrorMatchingInlineSnapshot(
+      '"Codec.fieldsUnion: Variant at index 1: Key \\"tag\\": Got a different encoded field name (\\"type\\") than before (\\"tag\\")."',
+    );
+  });
+
+  test("same encodedCommonField correctly used on every variant", () => {
+    const decoder = fieldsUnion("tag", [
+      { tag: tag("A", { renameFieldFrom: "type" }) },
+      { tag: tag("B", { renameFieldFrom: "type" }) },
+    ]);
+    expectType<
+      TypeEqual, { tag: "A" } | { tag: "B" }>
+    >(true);
+    expect(decoder({ type: "A" })).toStrictEqual({ tag: "A" });
+    expect(decoder({ type: "B" })).toStrictEqual({ tag: "B" });
+  });
+});
+
+describe("tag", () => {
+  test("basic", () => {
+    const { decoder } = tag("Test");
+    expectType, "Test">>(true);
+    expect(decoder("Test")).toBe("Test");
+    expect(run(decoder, "other")).toMatchInlineSnapshot(`
+      At root:
+      Expected this string: "Test"
+      Got: "other"
     `);
   });
 
-  test("keys must be strings", () => {
-    const innerDecoder = fieldsAuto({ tag: stringUnion(["1"]) });
-    // @ts-expect-error Type 'Decoder<{ 1: string; }, unknown>' is not assignable to type '"fieldsUnion keys must be strings, not numbers"'.
-    fieldsUnion("tag", { 1: innerDecoder });
-    // @ts-expect-error Type 'string' is not assignable to type 'Decoder'.
-    fieldsUnion("tag", { 1: "fieldsUnion keys must be strings, not numbers" });
-    const goodDecoder = fieldsUnion("tag", { "1": innerDecoder });
-    expectType, { tag: "1" }>>(true);
-    expect(goodDecoder({ tag: "1" })).toStrictEqual({ tag: "1" });
+  test("renamed", () => {
+    const { decoder } = tag("Test", { renameTagFrom: "test" });
+    expectType, "Test">>(true);
+    expect(decoder("test")).toBe("Test");
+    expect(run(decoder, "other")).toMatchInlineSnapshot(`
+      At root:
+      Expected this string: "test"
+      Got: "other"
+    `);
   });
 });