diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffedd7..66a32ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 22.0.0 (unreleased) + +This release renames `fieldsUnion` to `taggedUnion` since it better describes what it is, and it goes along better with the `tag` function. + ### Version 21.0.0 (2023-10-30) This release renames `nullable` to `nullOr` to be consistent with `undefinedOr`. diff --git a/README.md b/README.md index c6ebb1e..effbebd 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,11 @@ Here’s a summary of all codecs (with slightly simplified type annotations) and - Unions: - Of primitive literals: [primitiveUnion](#primitiveunion) - Of different types: [multi](#multi) - - Of tagged objects: [fieldsUnion](#fieldsunion) with [tag](#tag) + - Of tagged objects: [taggedUnion](#taggedunion) with [tag](#tag) - With undefined: [undefinedOr](#undefinedor) - With null: [nullOr](#nullOr) - Other unions: [untagged union example](examples/untagged-union.test.ts) -- Intersections: [intersection example](examples/fieldsUnion-with-common-fields.test.ts) +- Intersections: [intersection example](examples/taggedUnion-with-common-fields.test.ts) - Transformation: [map](#map), [flatMap](#flatmap) - Recursion: [recursive](#recursive) - Errors: [DecoderError](#decodererror), [format](#format), [repr](#repr) @@ -250,7 +250,7 @@ Here’s a summary of all codecs (with slightly simplified type annotations) and
fieldsAuto
( decodedCommonField: string, variants: Array< @@ -602,10 +602,10 @@ type Example = { > > All in all, you avoid a slight gotcha with optional fields and inferred types if you enable `exactOptionalPropertyTypes`. -### fieldsUnion +### taggedUnion ```ts -function fieldsUnion< +function taggedUnion< const DecodedCommonField extends keyof Variants[number], Variants extends readonly [ Variant, @@ -646,7 +646,7 @@ type Shape = | { tag: "Circle"; radius: number } | { tag: "Rectangle"; width: number; height: number }; -const shapeCodec: Codec = fieldsUnion("tag", [ +const shapeCodec: Codec = taggedUnion("tag", [ { tag: tag("Circle"), radius: number, @@ -664,7 +664,7 @@ The `allowExtraFields` option works just like for [fieldsAuto](#fieldsauto). See also these examples: - [Renaming union field](examples/renaming-union-field.test.ts) -- [`fieldsUnion` with common fields](examples/fieldsUnion-with-common-fields.test.ts) +- [`taggedUnion` with common fields](examples/taggedUnion-with-common-fields.test.ts) Note: If you use the same tag value twice, the last one wins. TypeScript infers a type with two variants with the same tag (which is a valid type), but tiny-decoders can’t tell them apart. Nothing will ever decode to the first one, only the last one will succeed. Trying to encode the first one might result in bad data. @@ -693,13 +693,13 @@ function tag< type primitive = bigint | boolean | number | string | symbol | null | undefined; ``` -Used with [fieldsUnion](#fieldsunion), once for each variant of the union. +Used with [taggedUnion](#taggedunion), once for each variant of the union. -`tag("MyTag")` returns a `Field` with a codec 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")` returns a `Field` with a codec that requires the input `"MyTag"` and returns `"MyTag"`. The metadata of the `Field` also advertises that the tag value is `"MyTag"`, which `taggedUnion` uses to know what to do. `tag("MyTag", { renameTagFrom: "my_tag" })` returns a `Field` with a codec that requires the input `"my_tag"` but returns `"MyTag"`. -For `renameFieldFrom`, see [fieldsUnion](#fieldsunion). +For `renameFieldFrom`, see [taggedUnion](#taggedunion). You will typically use string tags for your tagged unions, but other primitive types such as `boolean` and `number` are supported too. @@ -942,7 +942,7 @@ type DecoderError = { got: number; } | { - tag: "unknown fieldsUnion tag"; + tag: "unknown taggedUnion tag"; knownTags: Array ; got: unknown; } @@ -1160,6 +1160,6 @@ function either (codec1: Codec , codec2: Codec): Codec ; The decoder of this codec would try `codec1.decoder` first. If it fails, go on and try `codec2.decoder`. If that fails, present both errors. I consider this a blunt tool. - If you want either a string or a number, use [multi](#multi). This let’s you switch between any JSON types. -- For objects that can be decoded in different ways, use [fieldsUnion](#fieldsunion). If that’s not possible, see the [untagged union example](examples/untagged-union.test.ts) for how you can approach the problem. +- For objects that can be decoded in different ways, use [taggedUnion](#taggedunion). If that’s not possible, see the [untagged union example](examples/untagged-union.test.ts) for how you can approach the problem. The above approaches result in a much simpler [DecoderError](#decodererror) type, and also results in much better error messages, since there’s never a need to present something like “decoding failed in the following 2 ways: …” diff --git a/examples/renaming-union-field.test.ts b/examples/renaming-union-field.test.ts index 54d9ecf..5bb4e2e 100644 --- a/examples/renaming-union-field.test.ts +++ b/examples/renaming-union-field.test.ts @@ -1,12 +1,12 @@ import { expectType, TypeEqual } from "ts-expect"; import { expect, test } from "vitest"; -import { fieldsUnion, Infer, InferEncoded, number, tag } from "../"; +import { Infer, InferEncoded, number, tag, taggedUnion } from "../"; test("using different tags in JSON and in TypeScript", () => { // Here’s how to use different keys and values in JSON and TypeScript. // For example, `"type": "circle"` → `tag: "Circle"`. - const shapeCodec = fieldsUnion("tag", [ + const shapeCodec = taggedUnion("tag", [ { tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }), radius: number, diff --git a/examples/fieldsUnion-fallback.test.ts b/examples/taggedUnion-fallback.test.ts similarity index 83% rename from examples/fieldsUnion-fallback.test.ts rename to examples/taggedUnion-fallback.test.ts index 25cbac9..bc93a26 100644 --- a/examples/fieldsUnion-fallback.test.ts +++ b/examples/taggedUnion-fallback.test.ts @@ -1,12 +1,12 @@ import { expectType, TypeEqual } from "ts-expect"; import { expect, test } from "vitest"; -import { Codec, fieldsUnion, Infer, number, tag } from "../"; +import { Codec, Infer, number, tag, taggedUnion } from "../"; import { run } from "../tests/helpers"; -test("fieldsUnion with fallback for unknown tags", () => { +test("taggedUnion with fallback for unknown tags", () => { // Here’s a helper function that takes a codec – which is supposed to be a - // `fieldsUnion` codec – and makes it return `undefined` if the tag is unknown. + // `taggedUnion` codec – and makes it return `undefined` if the tag is unknown. function handleUnknownTag ( codec: Codec , ): Codec { @@ -15,8 +15,8 @@ test("fieldsUnion with fallback for unknown tags", () => { const decoderResult = codec.decoder(value); switch (decoderResult.tag) { case "DecoderError": - return decoderResult.error.path.length === 1 && // Don’t match on nested `fieldsUnion`. - decoderResult.error.tag === "unknown fieldsUnion tag" + return decoderResult.error.path.length === 1 && // Don’t match on nested `taggedUnion`. + decoderResult.error.tag === "unknown taggedUnion tag" ? { tag: "Valid", value: undefined } : decoderResult; case "Valid": @@ -28,12 +28,12 @@ test("fieldsUnion with fallback for unknown tags", () => { }; } - const shapeCodec = fieldsUnion("tag", [ + const shapeCodec = taggedUnion("tag", [ { tag: tag("Circle"), radius: number }, { tag: tag("Square"), side: number }, ]); - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("One") }, { tag: tag("Two"), value: shapeCodec }, ]); @@ -74,7 +74,7 @@ test("fieldsUnion with fallback for unknown tags", () => { `); expect(run(codecWithFallback, { tag: "Three" })).toBeUndefined(); - // A nested `fieldsUnion` still fails on unknown tags: + // A nested `taggedUnion` still fails on unknown tags: expect(run(codecWithFallback, { tag: "Two", value: { tag: "Rectangle" } })) .toMatchInlineSnapshot(` At root["value"]["tag"]: diff --git a/examples/fieldsUnion-with-common-fields.test.ts b/examples/taggedUnion-with-common-fields.test.ts similarity index 97% rename from examples/fieldsUnion-with-common-fields.test.ts rename to examples/taggedUnion-with-common-fields.test.ts index c41d26d..c2da01f 100644 --- a/examples/fieldsUnion-with-common-fields.test.ts +++ b/examples/taggedUnion-with-common-fields.test.ts @@ -5,16 +5,16 @@ import { boolean, Codec, fieldsAuto, - fieldsUnion, Infer, InferEncoded, number, string, tag, + taggedUnion, } from "../"; import { run } from "../tests/helpers"; -test("fieldsUnion with common fields", () => { +test("taggedUnion with common fields", () => { // This function takes two codecs for object types and returns // a new codec which is the intersection of those. // This function is not part of the tiny-decoders package because it has some caveats: @@ -56,7 +56,7 @@ test("fieldsUnion with common fields", () => { } type EventWithPayload = Infer ; - const EventWithPayload = fieldsUnion("event", [ + const EventWithPayload = taggedUnion("event", [ { event: tag("opened"), payload: string, diff --git a/examples/untagged-union.test.ts b/examples/untagged-union.test.ts index d3e72c7..1a7f84f 100644 --- a/examples/untagged-union.test.ts +++ b/examples/untagged-union.test.ts @@ -6,12 +6,12 @@ import { Codec, field, fieldsAuto, - fieldsUnion, Infer, InferEncoded, number, string, tag, + taggedUnion, unknown, } from "../"; import { run } from "../tests/helpers"; @@ -119,7 +119,7 @@ test("tagged tuples", () => { }, }; - // A function that takes a regular `fieldsUnion` codec, but makes it work on + // A function that takes a regular `taggedUnion` codec, but makes it work on // tagged tuples instead. function toArrayUnion< Decoded extends Record , @@ -141,7 +141,7 @@ test("tagged tuples", () => { type Shape = Infer ; const Shape = toArrayUnion( - fieldsUnion("tag", [ + taggedUnion("tag", [ { tag: tag("Circle", { renameFieldFrom: "0" }), radius: field(number, { renameFrom: "1" }), diff --git a/index.ts b/index.ts index 638958c..f253e6e 100644 --- a/index.ts +++ b/index.ts @@ -429,7 +429,7 @@ type Variant = Record< > & Record | Field >; -export function fieldsUnion< +export function taggedUnion< const DecodedCommonField extends keyof Variants[number], Variants extends readonly [ Variant , @@ -440,7 +440,7 @@ export function fieldsUnion< Variants[number] > extends never ? [ - "fieldsUnion variants must have a field in common, and their encoded field names must be the same", + "taggedUnion variants must have a field in common, and their encoded field names must be the same", never, ] : DecodedCommonField, @@ -451,7 +451,7 @@ export function fieldsUnion< InferEncodedFieldsUnion > { if (decodedCommonField === "__proto__") { - throw new Error("fieldsUnion: decoded common field cannot be __proto__"); + throw new Error("taggedUnion: decoded common field cannot be __proto__"); } type VariantCodec = Codec ; @@ -473,7 +473,7 @@ export function fieldsUnion< maybeEncodedCommonField = encodedFieldName; } else if (maybeEncodedCommonField !== encodedFieldName) { throw new Error( - `fieldsUnion: Variant at index ${index}: Key ${JSON.stringify( + `taggedUnion: Variant at index ${index}: Key ${JSON.stringify( decodedCommonField, )}: Got a different encoded field name (${JSON.stringify( encodedFieldName, @@ -487,7 +487,7 @@ export function fieldsUnion< if (typeof maybeEncodedCommonField !== "string") { throw new Error( - `fieldsUnion: Got unusable encoded common field: ${repr( + `taggedUnion: Got unusable encoded common field: ${repr( maybeEncodedCommonField, )}`, ); @@ -509,7 +509,7 @@ export function fieldsUnion< return { tag: "DecoderError", error: { - tag: "unknown fieldsUnion tag", + tag: "unknown taggedUnion tag", knownTags: Array.from(decoderMap.keys()), got: encodedName, path: [encodedCommonField], @@ -525,7 +525,7 @@ export function fieldsUnion< const encoder = encoderMap.get(decodedName); if (encoder === undefined) { throw new Error( - `fieldsUnion: Unexpectedly found no encoder for decoded variant name: ${JSON.stringify( + `taggedUnion: Unexpectedly found no encoder for decoded variant name: ${JSON.stringify( decodedName, )} at key ${JSON.stringify(decodedCommonField)}`, ); @@ -952,11 +952,6 @@ export type DecoderError = { expected: number; got: number; } - | { - tag: "unknown fieldsUnion tag"; - knownTags: Array ; - got: unknown; - } | { tag: "unknown multi type"; knownTypes: Array< @@ -975,6 +970,11 @@ export type DecoderError = { knownVariants: Array ; got: unknown; } + | { + tag: "unknown taggedUnion tag"; + knownTags: Array ; + got: unknown; + } | { tag: "wrong tag"; expected: primitive; @@ -1039,7 +1039,7 @@ function formatDecoderErrorVariant( : variant.knownTypes.join(", ") }\nGot: ${formatGot(variant.got)}`; - case "unknown fieldsUnion tag": + case "unknown taggedUnion tag": return `Expected one of these tags:${primitiveList( variant.knownTags, )}\nGot: ${formatGot(variant.got)}`; diff --git a/tests/codecs.test.ts b/tests/codecs.test.ts index b0b7e37..939fc1c 100644 --- a/tests/codecs.test.ts +++ b/tests/codecs.test.ts @@ -9,7 +9,6 @@ import { DecoderResult, field, fieldsAuto, - fieldsUnion, flatMap, Infer, InferEncoded, @@ -22,6 +21,7 @@ import { recursive, string, tag, + taggedUnion, tuple, undefinedOr, unknown, @@ -978,10 +978,10 @@ describe("fieldsAuto", () => { }); }); -describe("fieldsUnion", () => { +describe("taggedUnion", () => { test("string tags", () => { type Shape = Infer ; - const Shape = fieldsUnion("tag", [ + const Shape = taggedUnion("tag", [ { tag: tag("Circle"), radius: number, @@ -1059,7 +1059,7 @@ describe("fieldsUnion", () => { Got: "Square" `); - expect(run(fieldsUnion("0", [{ "0": tag("a") }]), ["a"])) + expect(run(taggedUnion("0", [{ "0": tag("a") }]), ["a"])) .toMatchInlineSnapshot(` At root: Expected an object @@ -1071,7 +1071,7 @@ describe("fieldsUnion", () => { test("boolean tags", () => { type User = Infer ; - const User = fieldsUnion("isAdmin", [ + const User = taggedUnion("isAdmin", [ { isAdmin: tag(true), name: string, @@ -1163,7 +1163,7 @@ describe("fieldsUnion", () => { const symbol2 = Symbol("symbol2"); type Type = Infer ; - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag(undefined) }, { tag: tag(null) }, { tag: tag(true) }, @@ -1240,7 +1240,7 @@ describe("fieldsUnion", () => { type Type = Infer ; type EncodedType = InferEncoded ; - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("undefined", { renameTagFrom: undefined }) }, { tag: tag("null", { renameTagFrom: null }) }, { tag: tag("true", { renameTagFrom: true }) }, @@ -1335,9 +1335,9 @@ describe("fieldsUnion", () => { test("__proto__ is not allowed", () => { expect(() => - fieldsUnion("__proto__", [{ __proto__: tag("Test") }]), + taggedUnion("__proto__", [{ __proto__: tag("Test") }]), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: decoded common field cannot be __proto__"', + '"taggedUnion: decoded common field cannot be __proto__"', ); }); @@ -1345,59 +1345,59 @@ describe("fieldsUnion", () => { 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", []), + taggedUnion("tag", []), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: Got unusable encoded common field: undefined"', + '"taggedUnion: 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") }]), + taggedUnion("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") }]), + taggedUnion("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 }]), + taggedUnion("tag", [{ tag: string }]), ).toThrow(); }); test("encodedCommonField mismatch", () => { expect(() => - // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '["fieldsUnion variants must have a field in common, and their encoded field names must be the same", never]'. - fieldsUnion("tag", [ + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '["taggedUnion variants must have a field in common, and their encoded field names must be the same", never]'. + taggedUnion("tag", [ { tag: tag("A") }, { tag: tag("B", { renameFieldFrom: "type" }) }, ]), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: Variant at index 1: Key \\"tag\\": Got a different encoded field name (\\"type\\") than before (\\"tag\\")."', + '"taggedUnion: Variant at index 1: Key \\"tag\\": Got a different encoded field name (\\"type\\") than before (\\"tag\\")."', ); }); test("encodedCommonField mismatch 2", () => { expect(() => - // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '["fieldsUnion variants must have a field in common, and their encoded field names must be the same", never]'. - fieldsUnion("tag", [ + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '["taggedUnion variants must have a field in common, and their encoded field names must be the same", never]'. + taggedUnion("tag", [ { tag: tag("A", { renameFieldFrom: "other" }) }, { tag: tag("B", { renameFieldFrom: "type" }) }, ]), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: Variant at index 1: Key \\"tag\\": Got a different encoded field name (\\"type\\") than before (\\"other\\")."', + '"taggedUnion: Variant at index 1: Key \\"tag\\": Got a different encoded field name (\\"type\\") than before (\\"other\\")."', ); }); test("same encodedCommonField correctly used on every variant", () => { - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("A", { renameFieldFrom: "type" }) }, { tag: tag("B", { renameFieldFrom: "type" }) }, ]); @@ -1418,7 +1418,7 @@ describe("fieldsUnion", () => { test("same tag used twice", () => { type Type = Infer ; - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("Test"), one: number }, { tag: tag("Test"), two: string }, ]); @@ -1479,7 +1479,7 @@ describe("fieldsUnion", () => { okCodec: Codec , errCodec: Codec , ): Codec > => - fieldsUnion("tag", [ + taggedUnion("tag", [ { tag: tag("Ok"), value: okCodec, @@ -1533,7 +1533,7 @@ describe("fieldsUnion", () => { okCodec: Codec , errCodec: Codec , ): Codec , Result > => - fieldsUnion("tag", [ + taggedUnion("tag", [ { tag: tag("Ok"), value: okCodec, @@ -1567,7 +1567,7 @@ describe("fieldsUnion", () => { }); test("always print the expected tags in full", () => { - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("PrettyLongTagName1"), value: string }, { tag: tag("PrettyLongTagName2"), value: string }, ]); @@ -1588,7 +1588,7 @@ describe("fieldsUnion", () => { }); test("unexpectedly found no encoder for decoded variant name", () => { - const codec = fieldsUnion("tag", [ + const codec = taggedUnion("tag", [ { tag: tag("One") }, { tag: tag("Two") }, ]); @@ -1597,7 +1597,7 @@ describe("fieldsUnion", () => { // @ts-expect-error Type '"Three"' is not assignable to type '"One" | "Two"'. codec.encoder({ tag: "Three" }), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: Unexpectedly found no encoder for decoded variant name: \\"Three\\" at key \\"tag\\""', + '"taggedUnion: Unexpectedly found no encoder for decoded variant name: \\"Three\\" at key \\"tag\\""', ); }); @@ -1605,7 +1605,7 @@ describe("fieldsUnion", () => { test("allows excess properties by default", () => { expect( run( - fieldsUnion("tag", [{ tag: tag("Test"), one: string, two: boolean }]), + taggedUnion("tag", [{ tag: tag("Test"), one: string, two: boolean }]), { tag: "Test", one: "a", @@ -1618,7 +1618,7 @@ describe("fieldsUnion", () => { expect( run( - fieldsUnion( + taggedUnion( "tag", [{ tag: tag("Test"), one: string, two: boolean }], { allowExtraFields: true }, @@ -1633,7 +1633,7 @@ describe("fieldsUnion", () => { ), ).toStrictEqual({ tag: "Test", one: "a", two: true }); - fieldsUnion("tag", [ + taggedUnion("tag", [ { tag: tag("Test"), one: string, two: boolean }, ]).encoder({ tag: "Test", @@ -1648,7 +1648,7 @@ describe("fieldsUnion", () => { test("fail on excess properties", () => { expect( run( - fieldsUnion( + taggedUnion( "tag", [{ tag: tag("Test"), one: string, two: boolean }], { allowExtraFields: false }, @@ -1672,7 +1672,7 @@ describe("fieldsUnion", () => { "four" `); - fieldsUnion("tag", [{ tag: tag("Test"), one: string, two: boolean }], { + taggedUnion("tag", [{ tag: tag("Test"), one: string, two: boolean }], { allowExtraFields: false, }).encoder({ tag: "Test", @@ -1687,7 +1687,7 @@ describe("fieldsUnion", () => { test("large number of excess properties", () => { expect( run( - fieldsUnion( + taggedUnion( "tag", [{ tag: tag("Test"), "1": boolean, "2": boolean }], { allowExtraFields: false }, @@ -1716,7 +1716,7 @@ describe("fieldsUnion", () => { }); test("always print the expected keys in full", () => { - const codec = fieldsUnion( + const codec = taggedUnion( "tag", [ {