diff --git a/CHANGELOG.md b/CHANGELOG.md index 068dd06..562efdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ 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 19.0.0 (unreleased) + +This release introduces `Codec`: + +```ts +type Codec = { + decoder: (value: unknown) => DecoderResult; + encoder: (value: Decoded) => Encoded; +}; +``` + +A codec is an object with a decoder and an encoder. + +The decoder of a codec is the `Decoder` type from previous versions of tiny-decoders. + +An encoder is a function that turns `Decoded` back into what the input looked like. You can think of it as “turning `Decoded` back into `unknown`”, but usually the `Encoded` type variable is inferred to something more precise. + +All functions in tiny-decoders have been changed to work with `Codec`s instead of `Decoder`s (and the `Decoder` type does not exist anymore – it is only part of the new `Codec` type). + +Overall, most things are the same. Things accept and return `Codec`s instead of `Decoder`s now, but many times it does not affect your code. + +The biggest changes are: + +- Unlike a `Decoder`, a `Codec` is not callable. You need to add `.decoder`. For example, change `myDecoder(data)` to `myDecoder.decoder(data)`. Then rename to `myCodec.decoder(data)` for clarity. +- `map` and `flatMap` now take _two_ functions: The same function as before for transforming the decoded data, but now also a second function for turning the data back again. This is usually trivial to implement. +- A custom `Decoder` was just a function. A custom `Codec` is an _object_ with `decoder` and `encoder` fields. Wrap your existing decoder function in such an object, and then implement the encoder (the inverse of the decoder). This is usually trivial as well. + +Finally, this release adds a couple of small things: + +- The `InferEncoded` utility type. `Infer` still infers the type for the decoder. `InferEncoded` infers the type for the _encoder._ +- The `unknown` codec. It’s occasionally useful, and now that you need to specify both a decoder and an encoder it crossed the triviality threshold for being included in the package. + +The motivations for codecs are: + +- TypeScript can now find some edge case errors that it couldn’t before: Extra fields in `fieldsAuto` and inconsistent encoded common fields in `fieldsUnion`. +- If you use `map`, `flatMap`, `field` and `tag` to turn JSON into nicer or more type safe types, you can now easily reverse that again when you need to serialize back to JSON. + ### Version 18.0.0 (2023-10-29) This release removes the second type variable from `Decoder`. diff --git a/README.md b/README.md index 86fd3e5..08811c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tiny-decoders [![minified size](https://img.shields.io/bundlephobia/min/tiny-decoders.svg)](https://bundlephobia.com/result?p=tiny-decoders) -Type-safe data decoding for the minimalist. +Type-safe data decoding and encoding for the minimalist. ## Installation @@ -8,7 +8,7 @@ Type-safe data decoding for the minimalist. npm install tiny-decoders ``` -- 👉 [Decoders summary](#decoders) +👉 [Codecs summary](#codecs) ## TypeScript requirements @@ -24,7 +24,6 @@ Note that it is possible to use tiny-decoders in plain JavaScript without type c import { array, boolean, - DecoderError, field, fieldsAuto, format, @@ -33,9 +32,18 @@ import { string, } from "tiny-decoders"; -// You can also import into a namespace if you want: -import * as Decode from "tiny-decoders"; +// You can also import into a namespace if you want (conventionally called `Codec`): +import * as Codec from "tiny-decoders"; +const userCodec = fieldsAuto({ + name: string, + active: field(boolean, { renameFrom: "is_active" }), + age: field(number, { optional: true }), + interests: array(string), +}); + +type User = Infer; +// equivalent to: type User = { name: string; active: boolean; @@ -43,16 +51,9 @@ type User = { interests: Array; }; -const userDecoder: Decoder = fieldsAuto({ - name: string, - active: field(boolean, { renameFrom: "is_active" }), - age: field(number, { optional: true }), - interests: array(string), -}); - const payload: unknown = getSomeJSON(); -const userResult: User = userDecoder(payload); +const userResult: DecoderResult = userCodec.decoder(payload); switch (userResult.tag) { case "DecoderError": @@ -73,47 +74,48 @@ Expected a number Got: "30" ``` -You can even [infer the type from the decoder](#type-inference) instead of writing it manually! +## Codec<T> and DecoderResult<T> ```ts -type User2 = Infer; -``` - -`User2` above is equivalent to the `User` type already shown earlier. - -## Decoder<T> and DecoderResult<T> - -```ts -type Decoder = (value: unknown) => DecoderResult; +type Codec = { + decoder: (value: unknown) => DecoderResult; + encoder: (value: Decoded) => Encoded; +}; -type DecoderResult = +type DecoderResult = | { tag: "DecoderError"; error: DecoderError; } | { tag: "Valid"; - value: T; + value: Decoded; }; ``` +A codec is an object with a decoder and an encoder. + A decoder is a function that: -- Takes an `unknown` value and refines it to any type you want (`T`). -- Returns a `DecoderResult`: Either that refined `T` or a [DecoderError](#decodererror). +- Takes an `unknown` value and refines it to any type you want (`Decoded`). +- Returns a `DecoderResult`: Either that refined `Decoded` or a [DecoderError](#decodererror). + +An encoder is a function that turns `Decoded` back into what the input looked like. You can think of it as “turning `Decoded` back into `unknown`”, but usually the `Encoded` type variable is inferred to something more precise. That’s it! -tiny-decoders ships with a bunch of decoders, and a few functions to combine decoders. This way you can describe the shape of any data! +tiny-decoders ships with a bunch of codecs, and a few functions to combine codecs. This way you can describe the shape of any data! -## Decoders +> tiny-decoders used to only have decoders, and not encoders. That’s why it’s called tiny-decoders and not tiny-codecs. Decoders are still the most interesting part. -Here’s a summary of all decoders (with slightly simplified type annotations): +## Codecs + +Here’s a summary of all codecs (with slightly simplified type annotations): - + @@ -121,20 +123,26 @@ Here’s a summary of all decoders (with slightly simplified type annotations): + + + + + + - + - + - + @@ -144,7 +152,7 @@ Here’s a summary of all decoders (with slightly simplified type annotations): "string2", "stringN" ]) => - Decoder< + Codec< "string1" | "string2" | "stringN" @@ -156,27 +164,27 @@ Here’s a summary of all decoders (with slightly simplified type annotations): - + - + @@ -216,7 +224,7 @@ Here’s a summary of all decoders (with slightly simplified type annotations): Parameters<typeof fieldsAuto>[0] >, ) => - Decoder<T1 | T2 | TN> + Codec<T1 | T2 | TN> @@ -231,12 +239,12 @@ Here’s a summary of all decoders (with slightly simplified type annotations): - + Codec<[T1, T2, TN]> @@ -247,7 +255,7 @@ Here’s a summary of all decoders (with slightly simplified type annotations): "type2", "type7" ]) => - Decoder< + Codec< { type: "type1", value: type1 } | { type: "type2", value: type2 } | { type: "type7", value: type7 } @@ -263,117 +271,135 @@ Here’s a summary of all decoders (with slightly simplified type annotations): - + - + - + + Codec<U> + Codec<U>
DecoderCodec Type JSON TypeScript
unknownCodec<unknown>anyunknown
booleanDecoder<boolean>Codec<boolean> boolean boolean
numberDecoder<number>Codec<number> number number
stringDecoder<string>Codec<string> string string
array
(decoder: Decoder<T>) =>
-  Decoder<Array<T>>
(decoder: Codec<T>) =>
+  Codec<Array<T>>
array Array<T>
record
(decoder: Decoder<T>) =>
-  Decoder<Record<string, T>>
(decoder: Codec<T>) =>
+  Codec<Record<string, T>>
object Record<string, T>
fieldsAuto
(mapping: {
-  field1: Decoder<T1>,
+  field1: Codec<T1>,
   field2: Field<T2, {optional: true}>,
   field3: Field<T3, {renameFrom: "field_3"}>,
-  fieldN: Decoder<TN>
+  fieldN: Codec<TN>
 }) =>
-  Decoder<{
+  Codec<{
     field1: T1,
     field2?: T2,
     field3: T3,
@@ -202,7 +210,7 @@ Here’s a summary of all decoders (with slightly simplified type annotations):
 
field
(
-  decoder: Decoder<Decoded>,
+  codec: Codec<Decoded>,
   meta: Meta,
 ): Field<Decoded, Meta>
n/a object T1 | T2 | TN
tuple
(mapping: [
-  Decoder<T1>,
-  Decoder<T2>,
-  Decoder<TN>
+
(codecs: [
+  Codec<T1>,
+  Codec<T2>,
+  Codec<TN>
 ]) =>
-  Decoder<[T1, T2, TN]>
array [T1, T2, TN]
recursive
(callback: () => Decoder<T>) =>
-  Decoder<T>
(callback: () => Codec<T>) =>
+  Codec<T>
n/a T
undefinedOr
(decoder: Decoder<T>) =>
-  Decoder<T | undefined>
(codec: Codec<T>) =>
+  Codec<T | undefined>
undefined or … T | undefined
nullable
(decoder: Decoder<T>) =>
-  Decoder<T | null>
(codec: Codec<T>) =>
+  Codec<T | null>
null or … T | null
map
(
-  decoder: Decoder<T>,
-  transform: (value: T) => U,
+  codec: Codec<T>,
+  transform: {
+    decoder: (value: T) => U;
+    encoder: (value: U) => T;
+  },
 ) =>
-  Decoder<U>
n/a U
flatMap
(
-  decoder: Decoder<T>,
-  transform: (value: T) => DecoderResult<U>,
+  decoder: Codec<T>,
+  transform: {
+    decoder: (value: T) => DecoderResult<U>;
+    encoder: (value: U) => T;
+  },
 ) =>
-  Decoder<U>
n/a U
+### unknown + +```ts +const unknown: Codec; +``` + +Codec for any JSON value, and a TypeScript `unknown`. Basically, both the decoder and encoder are identity functions. + ### boolean ```ts -function boolean(value: unknown): boolean; +const boolean: Codec; ``` -Decodes a JSON boolean into a TypeScript `boolean`. +Codec for a JSON boolean, and a TypeScript `boolean`. ### number ```ts -function number(value: unknown): number; +const number: Codec; ``` -Decodes a JSON number into a TypeScript `number`. +Codec for a JSON number, and a TypeScript `number`. ### string ```ts -function string(value: unknown): string; +const string: Codec; ``` -Decodes a JSON string into a TypeScript `string`. +Codec for a JSON string, and a TypeScript `string`. ### stringUnion ```ts -function stringUnion]>( - variants: T, -): Decoder; +function stringUnion< + const Variants extends readonly [string, ...Array], +>(variants: Variants): Codec; ``` -Decodes a set of specific JSON strings into a TypeScript union of those strings. +Codec for a set of specific JSON strings, and a TypeScript union of those strings. The `variants` is an array of the strings you want. You must provide at least one variant. -If you have an object and want to use its keys for a string union there’s an example of that in the [type inference file](examples/type-inference.test.ts). +If you have an object and want to use its keys for a string union there’s an example of that in the [type inference example](examples/type-inference.test.ts). Example: ```ts type Color = "green" | "red"; -const colorDecoder: Decoder = stringUnion(["green", "red"]); +const colorCodec: Codec = stringUnion(["green", "red"]); ``` ### array ```ts -function array(decoder: Decoder): Decoder>; +function array( + codec: Codec, +): Codec, Array>; ``` -Decodes a JSON array into a TypeScript `Array`. +Codec for a JSON array, and a TypeScript `Array`. -The passed `decoder` is for each item of the array. +The passed `codec` is for each item of the array. -For example, `array(string)` decodes an array of strings (into `Array`). +For example, `array(string)` is a codec for an array of strings (`Array`). ### record ```ts -function record(decoder: Decoder): Decoder>; +function record( + codec: Codec, +): Codec, Record>; ``` -Decodes a JSON object into a TypeScript `Record`. (Yes, this function is named after TypeScript’s type. Other languages call this a “dict”.) +Codec for a JSON object, and a TypeScript `Record`. (Yes, this function is named after TypeScript’s type. Other languages call this a “dict”.) -The passed `decoder` is for each value of the object. +The passed `codec` is for each value of the object. -For example, `record(number)` decodes an object where the keys can be anything and the values are numbers (into `Record`). +For example, `record(number)` is a codec for an object where the keys can be anything and the values are numbers (`Record`). ### fieldsAuto @@ -381,12 +407,12 @@ For example, `record(number)` decodes an object where the keys can be anything a function fieldsAuto( mapping: Mapping, { allowExtraFields = true }: { allowExtraFields?: boolean } = {}, -): Decoder>; +): Codec, InferEncodedFields>; -type FieldsMapping = Record | Field>; +type FieldsMapping = Record | Field>; -type Field = Meta & { - decoder: Decoder; +type Field = Meta & { + codec: Codec; }; type FieldMeta = { @@ -396,11 +422,13 @@ type FieldMeta = { }; type InferFields = magic; + +type InferEncodedFields = magic; ``` -Decodes a JSON object with certain fields into a TypeScript object type/interface with known fields. +Codec for a JSON object with certain fields, and a TypeScript object type/interface with known fields. -The `mapping` parameter is an object with the keys you want in your TypeScript object. The values are either `Decoder`s or `Field`s. A `Field` is just a `Decoder` with some metadata: Whether the field is optional, and whether the field has a different name in the JSON object. Passing a plain `Decoder` instead of a `Field` is just a convenience shortcut for passing a `Field` with the default metadata (the field is required, and has the same name both in TypeScript and in JSON). +The `mapping` parameter is an object with the keys you want in your TypeScript object. The values are either `Codec`s or `Field`s. A `Field` is just a `Codec` with some metadata: Whether the field is optional, and whether the field has a different name in the JSON object. Passing a plain `Codec` instead of a `Field` is just a convenience shortcut for passing a `Field` with the default metadata (the field is required, and has the same name both in TypeScript and in JSON). Use the [field](#field) function to create a `Field` – use it when you need to mark a field as optional, or when it has a different name in JSON than in TypeScript. @@ -413,7 +441,7 @@ type User = { active: boolean; }; -const userDecoder: Decoder = fieldsAuto({ +const userCodec: Codec = fieldsAuto({ name: string, age: field(number, { optional: true }), active: field(boolean, { renameFrom: "is_active" }), @@ -430,13 +458,13 @@ See also the [Extra fields](examples/extra-fields.test.ts) example. ### field ```ts -function field>( - decoder: Decoder, +function field>( + codec: Codec, meta: Meta, -): Field; +): Field; -type Field = Meta & { - decoder: Decoder; +type Field = Meta & { + codec: Codec; }; type FieldMeta = { @@ -446,7 +474,7 @@ type FieldMeta = { }; ``` -This function takes a decoder and lets you: +This function takes a codec and lets you: - Mark a field as optional: `field(string, { optional: true })` - Rename a field: `field(string, { renameFrom: "some_name" })` @@ -459,7 +487,7 @@ The `tag` thing is handled by the [tag](#tag) function. It’s not something you Here’s an example illustrating the difference between `field(string, { optional: true })` and `undefinedOr(string)`: ```ts -const exampleDecoder = fieldsAuto({ +const exampleCodec = fieldsAuto({ // Required field. a: string, @@ -474,7 +502,7 @@ const exampleDecoder = fieldsAuto({ }); ``` -The inferred type of `exampleDecoder` is: +The inferred type from `exampleCodec` is: ```ts type Example = { @@ -488,32 +516,32 @@ type Example = { > **Warning** > It is recommended to enable the [exactOptionalPropertyTypes](https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes) option in `tsconfig.json`. > -> Why? Let’s take this decoder as an example: +> Why? Let’s take this codec as an example: > > ```ts -> const exampleDecoder = fieldsAuto({ +> const exampleCodec = fieldsAuto({ > name: field(string, { optional: true }), > }); > ``` > -> With `exactOptionalPropertyTypes` enabled, the inferred type of `exampleDecoder` is: +> With `exactOptionalPropertyTypes` enabled, the inferred type for `exampleCodec` is: > > ```ts > type Example = { name?: string }; > ``` > -> That type allows constructing `{}` or `{ name: "some string" }`. If you pass either of those to `exampleDecoder` (such as `exampleDecoder({ name: "some string" })`), the decoder will succeed. It makes sense that a decoder accepts things that it has produced itself (when no transformation is involved). +> That type allows constructing `{}` or `{ name: "some string" }`. If you pass either of those to `exampleCodec.decoder` (such as `exampleCodec.decoder({ name: "some string" })`), the decoder will succeed. It makes sense that a decoder accepts things that it has produced itself (when no transformation is involved). > -> With `exactOptionalPropertyTypes` turned off (which is the default), the inferred type of `exampleDecoder` is: +> With `exactOptionalPropertyTypes` turned off (which is the default), the inferred type for `exampleCodec` is: > > ```ts > type Example = { name?: string | undefined }; > ``` > -> Notice the added `| undefined`. That allows also constructing `{ name: undefined }`. But if you run `exampleDecoder({ name: undefined })`, the decoder will fail. The decoder only supports `name` existing and being set to a string, or `name` being missing. It does not support it being set to `undefined` explicitly. If you wanted to support that, use `undefinedOr`: +> Notice the added `| undefined`. That allows also constructing `{ name: undefined }`. But if you run `exampleCodec.decoder({ name: undefined })`, the decoder will fail. The decoder only supports `name` existing and being set to a string, or `name` being missing. It does not support it being set to `undefined` explicitly. If you wanted to support that, use `undefinedOr`: > > ```ts -> const exampleDecoder = fieldsAuto({ +> const exampleCodec = fieldsAuto({ > name: field(undefinedOr(string), { optional: true }), > }); > ``` @@ -529,26 +557,31 @@ function fieldsUnion< const DecodedCommonField extends keyof Variants[number], Variants extends readonly [ Variant, - ...ReadonlyArray>, + ...Array>, ], >( decodedCommonField: DecodedCommonField, variants: Variants, { allowExtraFields = true }: { allowExtraFields?: boolean } = {}, -): Decoder>; +): Codec< + InferFieldsUnion, + InferEncodedFieldsUnion +>; -type Variant = Record< +type Variant = Record< DecodedCommonField, - Field + Field > & - Record | Field>; + Record | Field>; type InferFieldsUnion = magic; +type InferEncodedFieldsUnion = 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. +Codec for JSON objects with a common string field (that tells them apart), and a TypeScript tagged union type. The `decodedCommonField` is the name of the common string field. @@ -559,7 +592,7 @@ type Shape = | { tag: "Circle"; radius: number } | { tag: "Rectangle"; width: number; height: number }; -const shapeDecoder: Decoder = fieldsUnion("tag", [ +const shapeCodec: Codec = fieldsUnion("tag", [ { tag: tag("Circle"), radius: number, @@ -574,14 +607,17 @@ const shapeDecoder: Decoder = fieldsUnion("tag", [ The `allowExtraFields` option works just like for [fieldsAuto](#fieldsauto). -See also the [renaming union field example](examples/renaming-union-field.test.ts). +See also these examples: + +- [Renaming union field](examples/renaming-union-field.test.ts) +- [`fieldsUnion` with common fields](examples/fieldsUnion-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. ### tag ```ts -export function tag< +function tag< const Decoded extends string, const Encoded extends string, const EncodedFieldName extends string, @@ -596,6 +632,7 @@ export function tag< } = {}, ): Field< Decoded, + Encoded, { renameFrom: EncodedFieldName | undefined; tag: { decoded: string; encoded: string }; @@ -605,28 +642,32 @@ export function tag< 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")` 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", { renameTagFrom: "my_tag" })` returns a `Field` with a decoder that requires the input `"my_tag"` but returns `"MyTag"`. +`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). ### tuple ```ts -function tuple>( - mapping: readonly [...{ [P in keyof T]: Decoder }], -): Decoder<[...T]>; +function tuple>>( + codecs: Codecs, +): Codec, InferEncodedTuple>; + +type InferTuple>> = magic; + +type InferEncodedTuple>> = magic; ``` -Decodes a JSON array into a TypeScript tuple. They both must have the exact same length, otherwise the decoder fails. +Codec for a JSON array, and a TypeScript tuple. They both must have the exact same length, otherwise the decoder fails. Example: ```ts type Point = [number, number]; -const pointDecoder: Decoder = tuple([number, number]); +const pointCodec: Codec = tuple([number, number]); ``` See the [tuples example](examples/tuples.test.ts) for more details. @@ -634,9 +675,9 @@ See the [tuples example](examples/tuples.test.ts) for more details. ### multi ```ts -function multi]>( +function multi]>( types: Types, -): Decoder>; +): Codec, Multi["value"]>; type MultiTypeName = | "array" @@ -666,7 +707,7 @@ type Multi = Types extends any : never; ``` -Decode multiple JSON types into a TypeScript tagged union for those types. +Codec for multiple JSON types, and a TypeScript tagged union for those types. This is useful for supporting stuff that can be either a string or a number, for example. @@ -677,102 +718,130 @@ Example: ```ts type Id = { tag: "Id"; id: string } | { tag: "LegacyId"; id: number }; -const idDecoder: Decoder = map(multi(["string", "number"]), (value) => { - switch (value.type) { - case "string": - return { tag: "Id" as const, id: value.value }; - case "number": - return { tag: "LegacyId" as const, id: value.value }; - } +const idCodec: Codec = map(multi(["string", "number"]), { + decoder: (value) => { + switch (value.type) { + case "string": + return { tag: "Id" as const, id: value.value }; + case "number": + return { tag: "LegacyId" as const, id: value.value }; + } + }, + encoder: (id) => { + switch (id.tag) { + case "Id": + return { type: "string", value: id.id }; + case "LegacyId": + return { type: "number", value: id.id }; + } + }, }); ``` ### recursive ```ts -function recursive(callback: () => Decoder): Decoder; +function recursive( + callback: () => Codec, +): Codec; ``` -When you make a decoder for a recursive data structure, you might end up with errors like: +When you make a codec for a recursive data structure, you might end up with errors like: ``` -ReferenceError: Cannot access 'myDecoder' before initialization +ReferenceError: Cannot access 'myCodec' before initialization ``` -The solution is to wrap `myDecoder` in `recursive`: `recursive(() => myDecoder)`. The unnecessary-looking arrow function delays the reference to `myDecoder` so we’re able to define it. +The solution is to wrap `myCodec` in `recursive`: `recursive(() => myCodec)`. The unnecessary-looking arrow function delays the reference to `myCodec` so we’re able to define it. See the [recursive example](examples/recursive.test.ts) for more information. ### undefinedOr ```ts -function undefinedOr(decoder: Decoder): Decoder; +function undefinedOr( + codec: Codec, +): Codec; ``` -Returns a new decoder that also accepts `undefined`. +Returns a new codec that also accepts `undefined`. Notes: - Using `undefinedOr` does _not_ make a field in an object optional. It only allows the field to be `undefined`. Similarly, using the [field](#field) function to mark a field as optional does not allow setting the field to `undefined`, only omitting it. -- JSON does not have `undefined` (only `null`). So `undefinedOr` is more useful when you are decoding something that does not come from JSON. However, even when working with JSON `undefinedOr` still has a use: If you infer types from decoders, using `undefinedOr` on object fields results in `| undefined` for the type of the field, which allows you to assign `undefined` to it which is occasionally useful. +- JSON does not have `undefined` (only `null`). So `undefinedOr` is more useful when you are decoding something that does not come from JSON. However, even when working with JSON `undefinedOr` still has a use: If you infer types from codecs, using `undefinedOr` on object fields results in `| undefined` for the type of the field, which allows you to assign `undefined` to it which is occasionally useful. ### nullable ```ts -function nullable(decoder: Decoder): Decoder; +function nullOr( + codec: Codec, +): Codec; ``` -Returns a new decoder that also accepts `null`. +Returns a new codec that also accepts `null`. ### map ```ts -function map(decoder: Decoder, transform: (value: T) => U): Decoder; +function map( + codec: Codec, + transform: { + decoder: (value: Decoded) => NewDecoded; + encoder: (value: NewDecoded) => Readonly; + }, +): Codec; ``` -Run a function after a decoder (if it succeeds). The function transforms the decoded data. +Run a function (`transform.decoder`) after a decoder (if it succeeds). The function transforms the decoded data. `transform.encoder` turns the transformed data back again. Example: ```ts -const numberSetDecoder: Decoder> = map( - array(number), - (arr) => new Set(arr), -); +const numberSetCodec: Codec> = map(array(number), { + decoder: (arr) => new Set(arr), + encoder: Array.from, +}); ``` ### flatMap ```ts -function flatMap( - decoder: Decoder, - transform: (value: T) => DecoderResult, -): Decoder; +function flatMap( + codec: Codec, + transform: { + decoder: (value: Decoded) => DecoderResult; + encoder: (value: NewDecoded) => Readonly; + }, +): Codec; ``` -Run a function after a decoder (if it succeeds). The function decodes the decoded data further, returning another `DecoderResult` which is then “flattened” (so you don’t end up with a `DecoderResult` inside a `DecoderResult`). +Run a function (`transform.decoder`) after a decoder (if it succeeds). The function decodes the decoded data further, returning another `DecoderResult` which is then “flattened” (so you don’t end up with a `DecoderResult` inside a `DecoderResult`). `transform.encoder` turns the transformed data back again. Example: ```ts -const regexDecoder: Decoder = flatMap(string, (str) => { - try { - return { tag: "Valid", value: RegExp(str, "u") }; - } catch (error) { - return { - tag: "DecoderError", - error: { - tag: "custom", - message: error instanceof Error ? error.message : String(error), - got: str, - path: [], - }, - }; - } +const regexCodec: Codec = flatMap(string, { + decoder: (str) => { + try { + return { tag: "Valid", value: RegExp(str, "u") }; + } catch (error) { + return { + tag: "DecoderError", + error: { + tag: "custom", + message: error instanceof Error ? error.message : String(error), + got: str, + path: [], + }, + }; + } + }, + encoder: (regex) => regex.source, }); ``` -Note: Sometimes TypeScript has trouble inferring the return type of the `transform` function. No matter what you do, it keeps complaining. In such cases it helps to add return type annotation on the `transform` function. +Note: Sometimes TypeScript has trouble inferring the return type of the `transform.decoder` function. No matter what you do, it keeps complaining. In such cases it helps to add return type annotation on the `transform.decoder` function. ## DecoderError @@ -842,9 +911,9 @@ The error returned by all decoders. It keeps track of where in the JSON the erro Use the [format](#format) function to get a nice string explaining what went wrong. ```ts -const myDecoder = array(string); +const myCodec = array(string); -const decoderResult = myDecoder(someUnknownValue); +const decoderResult = myCodec.decoder(someUnknownValue); switch (decoderResult.tag) { case "DecoderError": console.error(format(decoderResult.error)); @@ -937,15 +1006,15 @@ It’s helpful when errors show you the actual values that failed decoding to ma ## Type inference -Rather than first defining the type and then defining the decoder (which often feels like writing the type twice), you can _only_ define the decoder and then infer the type. +Rather than first defining the type and then defining the codec (which often feels like writing the type twice), you can _only_ define the decoder and then infer the type. ```ts -const personDecoder = fieldsAuto({ +const personCodec = fieldsAuto({ name: string, age: number, }); -type Person = Infer; +type Person = Infer; // equivalent to: type Person = { name: string; @@ -953,32 +1022,30 @@ type Person = { }; ``` -See the [type inference example](examples/type-inference.test.ts) for more details. - -## Things left out - -Here are some decoders I’ve left out. They are rarely needed or not needed at all, and/or too trivial to be included in a decoding library _for the minimalist._ - -### unknown +This is a nice pattern (naming the type and the codec the same): ```ts -export function unknown(value: unknown): unknown { - return value; -} +type Person = Infer; +const Person = fieldsAuto({ + name: string, + age: number, +}); ``` -This decoder would turn any JSON value into TypeScript’s `unknown`. I rarely need that. When I do, there are other ways of achieving it – the `unknown` function above is just the identity function. See the [unknown example](examples/unknown.test.ts) for more details. +Note that if you don’t annotate a codec, TypeScript infers both type parameters of `Codec`. But if you annotate it with `Codec`, TypeScript does _not_ infer `Encoded` – it will become `unknown`. If you specify one type parameter, TypeScript stops inferring them altogether and requires you to specify _all_ of them – except the ones with defaults. `Encoded` defaults to `unknown`, which is usually fine, but occasionally you need to work with a more precise type for `Encoded`. Then it might even be easier to leave out the type annotation! + +See the [type inference example](examples/type-inference.test.ts) for more details. + +## Things left out ### either ```ts -export function either( - decoder1: Decoder, - decoder2: Decoder, -): Decoder; +// 🚨 Does not exist! +function either(codec1: Codec, codec2: Codec): Codec; ``` -This decoder would try `decoder1` first. If it fails, go on and try `decoder2`. If that fails, present both errors. I consider this a blunt tool. +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. diff --git a/examples/decode-constrained.test.ts b/examples/decode-constrained.test.ts new file mode 100644 index 0000000..dec9d6a --- /dev/null +++ b/examples/decode-constrained.test.ts @@ -0,0 +1,70 @@ +import "../tests/helpers"; + +import { expect, test } from "vitest"; + +import { + Codec, + DecoderResult, + fieldsAuto, + format, + string, + stringUnion, +} from "../"; + +test("decode constrained", () => { + const codec1 = fieldsAuto({ + status: string, // In a first codec, we have a pretty loose type. + }); + + const codec2 = fieldsAuto({ + status: stringUnion(["ok", "error"]), // In a second codec, we have a stricter type. + }); + + // `.decoder` of a codec usually accepts `unknown` – you can pass in anything. + // This function constrains us constrain so we can only decode what the codec + // has encoded. + function decodeConstrained( + codec: Codec, + value: Encoded, + ): DecoderResult { + return codec.decoder(value); + } + + const result1 = codec1.decoder({ status: "ok" }); + + if (result1.tag === "DecoderError") { + throw new Error(format(result1.error)); + } + + const result2 = decodeConstrained(codec2, result1.value); + + if (result2.tag === "DecoderError") { + throw new Error(format(result2.error)); + } + + // With `decodeConstrained` it’s not possible to accidentally decode the wrong thing: + // @ts-expect-error Argument of type '{ tag: "Valid"; value: { status: string; }; }' is not assignable to parameter of type '{ status: "ok" | "error"; }'. + // Property 'status' is missing in type '{ tag: "Valid"; value: { status: string; }; }' but required in type '{ status: "ok" | "error"; }'. + decodeConstrained(codec2, result1); + // @ts-expect-error Type 'number' is not assignable to type '"ok" | "error"'. + decodeConstrained(codec2, { status: 0 }); + // @ts-expect-error Type '"other"' is not assignable to type '"ok" | "error"'. + decodeConstrained(codec2, { status: "other" as const }); + + // I’m not sure why TypeScript allows `string` to be assignable to `"ok" | "error"`, + // but in this case it helps us since a string is what we have and want to decode further. + const decoderResult = decodeConstrained(codec2, { status: "other" }); + expect( + decoderResult.tag === "DecoderError" + ? format(decoderResult.error) + : decoderResult, + ).toMatchInlineSnapshot(` + At root["status"]: + Expected one of these variants: + "ok", + "error" + Got: "other" + `); + + expect(result2.value).toStrictEqual({ status: "ok" }); +}); diff --git a/examples/extra-fields.test.ts b/examples/extra-fields.test.ts index 90884f9..b10588a 100644 --- a/examples/extra-fields.test.ts +++ b/examples/extra-fields.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { Decoder, fieldsAuto, map, number, string } from ".."; +import { Codec, fieldsAuto, map, number, string } from ".."; import { run } from "../tests/helpers"; test("adding extra fields to records", () => { @@ -15,15 +15,18 @@ test("adding extra fields to records", () => { const data: unknown = { name: "Comfortable Bed", price: 10e3 }; // Use `map` to add it: - const productDecoder: Decoder = map( + const productCodec: Codec = map( fieldsAuto({ name: string, price: number, }), - (props) => ({ ...props, version: 1 }), + { + decoder: (props) => ({ ...props, version: 1 }), + encoder: ({ version: _version, ...props }) => props, + }, ); - expect(productDecoder(data)).toMatchInlineSnapshot(` + expect(productCodec.decoder(data)).toMatchInlineSnapshot(` { "tag": "Valid", "value": { @@ -34,17 +37,33 @@ test("adding extra fields to records", () => { } `); + expect( + productCodec.encoder({ + name: "Comfortable Bed", + price: 10000, + version: 1, + }), + ).toMatchInlineSnapshot(` + { + "name": "Comfortable Bed", + "price": 10000, + } + `); + // In previous versions of tiny-decoders, another way of doing this was to add // a decoder that always succeeds (a function that ignores its input and // always returns the same value). - const productDecoderBroken: Decoder = fieldsAuto({ + const productCodecBroken: Codec = fieldsAuto({ name: string, price: number, - version: () => ({ tag: "Valid", value: 1 }), + version: { + decoder: () => ({ tag: "Valid", value: 1 }), + encoder: () => undefined, + }, }); // It no longer works, because all the fields you mentioned are expected to exist. - expect(run(productDecoderBroken, data)).toMatchInlineSnapshot(` + expect(run(productCodecBroken, data)).toMatchInlineSnapshot(` At root: Expected an object with a field called: "version" Got: { diff --git a/examples/fieldsUnion-fallback.test.ts b/examples/fieldsUnion-fallback.test.ts index a20ccdc..25cbac9 100644 --- a/examples/fieldsUnion-fallback.test.ts +++ b/examples/fieldsUnion-fallback.test.ts @@ -1,42 +1,48 @@ import { expectType, TypeEqual } from "ts-expect"; import { expect, test } from "vitest"; -import { Decoder, fieldsUnion, Infer, number, tag } from "../"; +import { Codec, fieldsUnion, Infer, number, tag } from "../"; import { run } from "../tests/helpers"; test("fieldsUnion with fallback for unknown tags", () => { - // Here’s a helper function that takes a decoder – which is supposed to be a - // `fieldsUnion` decoder – and makes it return `undefined` if the tag is unknown. - function handleUnknownTag(decoder: Decoder): Decoder { - return (value) => { - const decoderResult = 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" - ? { tag: "Valid", value: undefined } - : decoderResult; - case "Valid": - return decoderResult; - } + // 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. + function handleUnknownTag( + codec: Codec, + ): Codec { + return { + decoder: (value) => { + 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" + ? { tag: "Valid", value: undefined } + : decoderResult; + case "Valid": + return decoderResult; + } + }, + encoder: (value) => + value === undefined ? undefined : codec.encoder(value), }; } - const shapeDecoder = fieldsUnion("tag", [ + const shapeCodec = fieldsUnion("tag", [ { tag: tag("Circle"), radius: number }, { tag: tag("Square"), side: number }, ]); - const decoder = fieldsUnion("tag", [ + const codec = fieldsUnion("tag", [ { tag: tag("One") }, - { tag: tag("Two"), value: shapeDecoder }, + { tag: tag("Two"), value: shapeCodec }, ]); - const decoderWithFallback = handleUnknownTag(decoder); + const codecWithFallback = handleUnknownTag(codec); expectType< TypeEqual< - Infer, + Infer, | { tag: "One"; } @@ -50,29 +56,26 @@ test("fieldsUnion with fallback for unknown tags", () => { >(true); expectType< - TypeEqual< - Infer | undefined, - Infer - > + TypeEqual | undefined, Infer> >(true); - expect(run(decoder, { tag: "One" })).toStrictEqual({ tag: "One" }); - expect(run(decoderWithFallback, { tag: "One" })).toStrictEqual({ + expect(run(codec, { tag: "One" })).toStrictEqual({ tag: "One" }); + expect(run(codecWithFallback, { tag: "One" })).toStrictEqual({ tag: "One", }); // The original decoder fails on unknown tags, while the other one returns `undefined`. - expect(run(decoder, { tag: "Three" })).toMatchInlineSnapshot(` + expect(run(codec, { tag: "Three" })).toMatchInlineSnapshot(` At root["tag"]: Expected one of these tags: "One", "Two" Got: "Three" `); - expect(run(decoderWithFallback, { tag: "Three" })).toBeUndefined(); + expect(run(codecWithFallback, { tag: "Three" })).toBeUndefined(); // A nested `fieldsUnion` still fails on unknown tags: - expect(run(decoderWithFallback, { tag: "Two", value: { tag: "Rectangle" } })) + expect(run(codecWithFallback, { tag: "Two", value: { tag: "Rectangle" } })) .toMatchInlineSnapshot(` At root["value"]["tag"]: Expected one of these tags: diff --git a/examples/fieldsUnion-with-common-fields.test.ts b/examples/fieldsUnion-with-common-fields.test.ts new file mode 100644 index 0000000..c744cb0 --- /dev/null +++ b/examples/fieldsUnion-with-common-fields.test.ts @@ -0,0 +1,175 @@ +import { expectType, TypeEqual } from "ts-expect"; +import { expect, test } from "vitest"; + +import { + boolean, + Codec, + DecoderResult, + fieldsAuto, + fieldsUnion, + Infer, + InferEncoded, + number, + string, + tag, +} from "../"; +import { run } from "../tests/helpers"; + +test("fieldsUnion with common fields", () => { + type EventWithPayload = Infer; + const EventWithPayload = fieldsUnion("event", [ + { + event: tag("opened"), + payload: string, + }, + { + event: tag("closed"), + payload: number, + }, + { + event: tag("reopened", { renameTagFrom: "undo_closed" }), + payload: boolean, + }, + ]); + + type EventMetadata = Infer; + const EventMetadata = fieldsAuto({ + id: string, + timestamp: string, + }); + + type EncodedEvent = InferEncoded & + InferEncoded; + + type Event = EventMetadata & EventWithPayload; + + const Event: Codec = { + decoder: (value: unknown): DecoderResult => { + const eventMetadataResult = EventMetadata.decoder(value); + if (eventMetadataResult.tag === "DecoderError") { + return eventMetadataResult; + } + const eventWithPayloadResult = EventWithPayload.decoder(value); + if (eventWithPayloadResult.tag === "DecoderError") { + return eventWithPayloadResult; + } + return { + tag: "Valid", + value: { + ...eventMetadataResult.value, + ...eventWithPayloadResult.value, + }, + }; + }, + encoder: (event: Event): EncodedEvent => ({ + ...EventMetadata.encoder(event), + ...EventWithPayload.encoder(event), + }), + }; + + expectType< + TypeEqual< + Event, + { + id: string; + timestamp: string; + } & ( + | { + event: "closed"; + payload: number; + } + | { + event: "opened"; + payload: string; + } + | { + event: "reopened"; + payload: boolean; + } + ) + > + >(true); + + expectType< + TypeEqual< + EncodedEvent, + { + id: string; + timestamp: string; + } & ( + | { + event: "closed"; + payload: number; + } + | { + event: "opened"; + payload: string; + } + | { + event: "undo_closed"; + payload: boolean; + } + ) + > + >(true); + + expect( + run(Event, { + id: "1", + timestamp: "2023-10-29", + event: "undo_closed", + payload: true, + }), + ).toStrictEqual({ + id: "1", + timestamp: "2023-10-29", + event: "reopened", + payload: true, + }); + + expect( + Event.encoder({ + id: "1", + timestamp: "2023-10-29", + event: "reopened", + payload: true, + }), + ).toStrictEqual({ + id: "1", + timestamp: "2023-10-29", + event: "undo_closed", + payload: true, + }); + + expect( + run(Event, { + timestamp: "2023-10-29", + event: "undo_closed", + payload: true, + }), + ).toMatchInlineSnapshot(` + At root: + Expected an object with a field called: "id" + Got: { + "timestamp": "2023-10-29", + "event": "undo_closed", + "payload": true + } + `); + + expect( + run(Event, { + id: "1", + timestamp: "2023-10-29", + event: "other", + payload: true, + }), + ).toMatchInlineSnapshot(` + At root["event"]: + Expected one of these tags: + "opened", + "closed", + "undo_closed" + Got: "other" + `); +}); diff --git a/examples/readme.test.ts b/examples/readme.test.ts index 58eaa9c..d0eff41 100644 --- a/examples/readme.test.ts +++ b/examples/readme.test.ts @@ -4,7 +4,7 @@ import { expect, test } from "vitest"; import { array, boolean, - Decoder, + Codec, DecoderResult, field, fieldsAuto, @@ -23,7 +23,7 @@ test("the main readme example", () => { interests: Array; }; - const userDecoder: Decoder = fieldsAuto({ + const userCodec: Codec = fieldsAuto({ name: string, active: field(boolean, { renameFrom: "is_active" }), age: field(number, { optional: true }), @@ -32,7 +32,7 @@ test("the main readme example", () => { const payload: unknown = getSomeJSON(); - const userResult: DecoderResult = userDecoder(payload); + const userResult: DecoderResult = userCodec.decoder(payload); expect(userResult).toStrictEqual({ tag: "Valid", @@ -46,7 +46,7 @@ test("the main readme example", () => { const payload2: unknown = getSomeInvalidJSON(); - expect(run(userDecoder, payload2)).toMatchInlineSnapshot(` + expect(run(userCodec, payload2)).toMatchInlineSnapshot(` At root["age"]: Expected a number Got: "30" @@ -72,7 +72,7 @@ function getSomeInvalidJSON(): unknown { } test("default vs sensitive error messages", () => { - const userDecoder = fieldsAuto({ + const userCodec = fieldsAuto({ name: string, details: fieldsAuto({ email: string, @@ -88,13 +88,13 @@ test("default vs sensitive error messages", () => { }, }; - expect(run(userDecoder, data)).toMatchInlineSnapshot(` + expect(run(userCodec, data)).toMatchInlineSnapshot(` At root["details"]["ssn"]: Expected a string Got: 123456789 `); - expect(run(userDecoder, data, { sensitive: true })).toMatchInlineSnapshot(` + expect(run(userCodec, data, { sensitive: true })).toMatchInlineSnapshot(` At root["details"]["ssn"]: Expected a string Got: number @@ -102,35 +102,35 @@ test("default vs sensitive error messages", () => { `); }); -test("fieldsAuto", () => { - const exampleDecoder = fieldsAuto({ +test("fieldsAuto exactOptionalPropertyTypes", () => { + const exampleCodec = fieldsAuto({ name: field(string, { optional: true }), }); type Example = { name?: string }; - expectType, Example>>(true); + expectType, Example>>(true); - const exampleDecoder2 = fieldsAuto({ + const exampleCodec2 = fieldsAuto({ name: field(undefinedOr(string), { optional: true }), }); - expect(run(exampleDecoder, {})).toStrictEqual({}); - expect(run(exampleDecoder, { name: "some string" })).toStrictEqual({ + expect(run(exampleCodec, {})).toStrictEqual({}); + expect(run(exampleCodec, { name: "some string" })).toStrictEqual({ name: "some string", }); type Example2 = { name?: string | undefined }; - expectType, Example2>>(true); + expectType, Example2>>(true); - expect(run(exampleDecoder2, { name: undefined })).toStrictEqual({ + expect(run(exampleCodec2, { name: undefined })).toStrictEqual({ name: undefined, }); }); -test("field", () => { - const exampleDecoder = fieldsAuto({ +test("fieldAuto optional vs undefined", () => { + const exampleCodec = fieldsAuto({ // Required field. a: string, @@ -151,18 +151,18 @@ test("field", () => { d?: string | undefined; }; - expectType, Example>>(true); + expectType, Example>>(true); - expect(run(exampleDecoder, { a: "", c: undefined })).toStrictEqual({ + expect(run(exampleCodec, { a: "", c: undefined })).toStrictEqual({ a: "", c: undefined, }); expect( - run(exampleDecoder, { a: "", b: "", c: undefined, d: undefined }), + run(exampleCodec, { a: "", b: "", c: undefined, d: undefined }), ).toStrictEqual({ a: "", b: "", c: undefined, d: undefined }); - expect(run(exampleDecoder, { a: "", b: "", c: "", d: "" })).toStrictEqual({ + expect(run(exampleCodec, { a: "", b: "", c: "", d: "" })).toStrictEqual({ a: "", b: "", c: "", diff --git a/examples/recursive.test.ts b/examples/recursive.test.ts index c14d254..0dcc5d1 100644 --- a/examples/recursive.test.ts +++ b/examples/recursive.test.ts @@ -2,7 +2,7 @@ import { expect, test, vi } from "vitest"; import { array, - Decoder, + Codec, DecoderResult, fieldsAuto, flatMap, @@ -22,7 +22,7 @@ test("recursive data structure", () => { // This wouldn’t work to decode it, because we’re trying to use // `personDecoder` in the definition of `personDecoder` itself. /* - const personDecoder = fieldsAuto({ + const personCodec = fieldsAuto({ name: string, friends: array(personDecoder2), // ReferenceError: Cannot access 'personDecoder2' before initialization }); @@ -30,9 +30,9 @@ test("recursive data structure", () => { // `recursive` lets us delay when `personDecoder` is referenced, solving the // issue. - const personDecoder: Decoder = fieldsAuto({ + const personCodec: Codec = fieldsAuto({ name: string, - friends: array(recursive(() => personDecoder)), + friends: array(recursive(() => personCodec)), }); const data: unknown = { @@ -54,7 +54,7 @@ test("recursive data structure", () => { ], }; - expect(personDecoder(data)).toMatchInlineSnapshot(` + expect(personCodec.decoder(data)).toMatchInlineSnapshot(` { "tag": "Valid", "value": { @@ -82,20 +82,23 @@ test("recursive data structure", () => { test("recurse non-record", () => { type Dict = { [key: string]: Dict | number }; - const dictDecoder: Decoder = record( - flatMap( - multi(["number", "object"]), - (value): DecoderResult => { + const dictCodec: Codec = record( + flatMap(multi(["number", "object"]), { + decoder: (value): DecoderResult => { switch (value.type) { case "number": return { tag: "Valid", value: value.value }; case "object": // Thanks to the arrow function we’re in, the reference to - // `dictDecoder` is delayed and therefore works. - return dictDecoder(value.value); + // `dictCodec` is delayed and therefore works. + return dictCodec.decoder(value.value); } }, - ), + encoder: (value) => + typeof value === "number" + ? { type: "number", value } + : { type: "object", value }, + }), ); const data: unknown = { @@ -110,7 +113,7 @@ test("recurse non-record", () => { }, }; - expect(dictDecoder(data)).toMatchInlineSnapshot(` + expect(dictCodec.decoder(data)).toMatchInlineSnapshot(` { "tag": "Valid", "value": { @@ -136,9 +139,9 @@ test("circular objects", () => { likes: Person; }; - const personDecoder: Decoder = fieldsAuto({ + const personCodec: Codec = fieldsAuto({ name: string, - likes: recursive(() => personDecoder), + likes: recursive(() => personCodec), }); const alice: Record = { @@ -157,7 +160,7 @@ test("circular objects", () => { // Calling the decoder would cause infinite recursion! // So be careful when working with recursive data! const wouldCauseInfiniteRecursion1: () => DecoderResult = vi.fn(() => - personDecoder(alice), + personCodec.decoder(alice), ); expect(wouldCauseInfiniteRecursion1).not.toHaveBeenCalled(); diff --git a/examples/renaming-union-field.test.ts b/examples/renaming-union-field.test.ts index 711471f..54d9ecf 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, number, tag } from "../"; +import { fieldsUnion, Infer, InferEncoded, number, tag } 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 decoder = fieldsUnion("tag", [ + const shapeCodec = fieldsUnion("tag", [ { tag: tag("Circle", { renameTagFrom: "circle", renameFieldFrom: "type" }), radius: number, @@ -17,13 +17,20 @@ test("using different tags in JSON and in TypeScript", () => { }, ]); - type InferredType = Infer; + type InferredType = Infer; type ExpectedType = | { tag: "Circle"; radius: number } | { tag: "Square"; size: number }; expectType>(true); - expect(decoder({ type: "circle", radius: 5 })).toMatchInlineSnapshot(` + type InferredEncodedType = InferEncoded; + type ExpectedEncodedType = + | { type: "circle"; radius: number } + | { type: "square"; size: number }; + expectType>(true); + + expect(shapeCodec.decoder({ type: "circle", radius: 5 })) + .toMatchInlineSnapshot(` { "tag": "Valid", "value": { @@ -32,4 +39,12 @@ test("using different tags in JSON and in TypeScript", () => { }, } `); + + expect(shapeCodec.encoder({ tag: "Circle", radius: 5 })) + .toMatchInlineSnapshot(` + { + "radius": 5, + "type": "circle", + } + `); }); diff --git a/examples/tuples.test.ts b/examples/tuples.test.ts index de18860..a4933b4 100644 --- a/examples/tuples.test.ts +++ b/examples/tuples.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { Decoder, fieldsAuto, map, number, tuple } from "../"; +import { Codec, fieldsAuto, map, number, tuple } from "../"; test("decoding tuples", () => { type PointTuple = [number, number]; @@ -8,8 +8,8 @@ test("decoding tuples", () => { const data: unknown = [50, 325]; // If you want a quick way to decode the above into `[number, number]`, use `tuple`. - const pointTupleDecoder1 = tuple([number, number]); - expect(pointTupleDecoder1(data)).toMatchInlineSnapshot(` + const pointTupleCodec1: Codec = tuple([number, number]); + expect(pointTupleCodec1.decoder(data)).toMatchInlineSnapshot(` { "tag": "Valid", "value": [ @@ -19,19 +19,19 @@ test("decoding tuples", () => { } `); - // If you’d rather produce an object like the following, use `tuple` with `chain`. + // If you’d rather produce an object like the following, use `tuple` with `map`. type Point = { x: number; y: number; }; - const pointDecoder: Decoder = map( - tuple([number, number]), - ([x, y]) => ({ + const pointCodec: Codec = map(tuple([number, number]), { + decoder: ([x, y]) => ({ x, y, }), - ); - expect(pointDecoder(data)).toMatchInlineSnapshot(` + encoder: ({ x, y }) => [x, y] as const, + }); + expect(pointCodec.decoder(data)).toMatchInlineSnapshot(` { "tag": "Valid", "value": { @@ -40,9 +40,15 @@ test("decoding tuples", () => { }, } `); + expect(pointCodec.encoder({ x: 50, y: 325 })).toMatchInlineSnapshot(` + [ + 50, + 325, + ] + `); // `tuple` works with any number of values. Here’s an example with four values: - expect(tuple([number, number, number, number])([1, 2, 3, 4])) + expect(tuple([number, number, number, number]).decoder([1, 2, 3, 4])) .toMatchInlineSnapshot(` { "tag": "Valid", @@ -57,14 +63,17 @@ test("decoding tuples", () => { // You can of course decode an object to a tuple as well: const obj: unknown = { x: 1, y: 2 }; - const pointTupleDecoder: Decoder = map( + const pointTupleCodec: Codec = map( fieldsAuto({ x: number, y: number, }), - ({ x, y }) => [x, y], + { + decoder: ({ x, y }) => [x, y], + encoder: ([x, y]) => ({ x, y }), + }, ); - expect(pointTupleDecoder(obj)).toMatchInlineSnapshot(` + expect(pointTupleCodec.decoder(obj)).toMatchInlineSnapshot(` { "tag": "Valid", "value": [ @@ -73,4 +82,10 @@ test("decoding tuples", () => { ], } `); + expect(pointTupleCodec.encoder([1, 2])).toMatchInlineSnapshot(` + { + "x": 1, + "y": 2, + } + `); }); diff --git a/examples/type-annotations.test.ts b/examples/type-annotations.test.ts index 6e1b62c..2ff8c8f 100644 --- a/examples/type-annotations.test.ts +++ b/examples/type-annotations.test.ts @@ -1,9 +1,6 @@ -// This file shows how best to annotate your `fields` and `fieldsAuto` decoders -// to maximize the help you get from TypeScript. - import { expect, test } from "vitest"; -import { Decoder, DecoderResult, fieldsAuto, number, string } from "../"; +import { Codec, DecoderResult, fieldsAuto, number, string } from "../"; test("type annotations", () => { // First, a small test type and a function that receives it: @@ -25,29 +22,28 @@ test("type annotations", () => { * MISSPELLED PROPERTY */ - // Here’s a decoder for `Person`, but without an explicit type annotation. - // TypeScript will infer what they decode into (try hovering `personDecoder1` + // Here’s a codec for `Person`, but without an explicit type annotation. + // TypeScript will infer what it decodes into (try hovering `personCodec1` // in your editor!), but it won’t know that you intended to decode a `Person`. // As you can see, I’ve misspelled `age` as `aye`. - const personDecoder1 = fieldsAuto({ + const personCodec1 = fieldsAuto({ name: string, aye: number, }); - // Since TypeScript has inferred a legit decoder above, it marks the following + // Since TypeScript has inferred a legit codec above, it marks the following // call as an error (you can’t pass an object with `aye` as a `Person`), - // while the _real_ error of course is in the decoder itself. + // while the _real_ error of course is in the codec itself. // @ts-expect-error Property 'age' is missing in type '{ name: string; aye: number; }' but required in type 'Person'. - greet(personDecoder1(testPerson)); + greet(personCodec1.decoder(testPerson)); // The way to make the above type error more clear is to provide an explicit type // annotation, so that TypeScript knows what you’re trying to do. - // @ts-expect-error Type 'Decoder<{ name: string; aye: number; }>' is not assignable to type 'Decoder'. - // Property 'age' is missing in type '{ name: string; aye: number; }' but required in type 'Person'. - const personDecoder2: Decoder = fieldsAuto({ + // @ts-expect-error Type '{ name: string; aye: number; }' is not assignable to type 'Person'. + const personCodec2: Codec = fieldsAuto({ name: string, aye: number, }); - greet(personDecoder2(testPerson)); + greet(personCodec2.decoder(testPerson)); /* * EXTRA PROPERTY @@ -55,42 +51,31 @@ test("type annotations", () => { // TypeScript allows passing extra properties, so without type annotations // there are no errors: - const personDecoder3 = fieldsAuto({ + const personCodec3 = fieldsAuto({ name: string, age: number, extra: string, }); // This would ideally complain about the extra property, but it doesn’t. - greet(personDecoder3(testPerson)); - - // Adding `Decoder` does not seem to help TypeScript find any errors: - const personDecoder4: Decoder = fieldsAuto({ - name: string, - age: number, - extra: string, - }); - greet(personDecoder4(testPerson)); + greet(personCodec3.decoder(testPerson)); - // This is currently not an error unfortunately, but in a future version of tiny-decoders it will be. - const personDecoder5: Decoder = fieldsAuto({ + // Adding `Codec` helps TypeScript find the error: + // @ts-expect-error Type 'Person' is not assignable to type '{ name: string; age: number; extra: string; }'. + const personCodec4: Codec = fieldsAuto({ name: string, age: number, - // Here is where the error will be. extra: string, }); - greet(personDecoder5(testPerson)); - // See these TypeScript issues for more information: - // https://github.com/microsoft/TypeScript/issues/7547 - // https://github.com/microsoft/TypeScript/issues/18020 + greet(personCodec4.decoder(testPerson)); - // Finally, a compiling decoder. - const personDecoder6: Decoder = fieldsAuto({ + // Finally, a compiling codec. + const personCodec5: Codec = fieldsAuto({ name: string, age: number, }); - greet(personDecoder6(testPerson)); + greet(personCodec5.decoder(testPerson)); - expect(personDecoder6(testPerson)).toMatchInlineSnapshot(` + expect(personCodec5.decoder(testPerson)).toMatchInlineSnapshot(` { "tag": "Valid", "value": { diff --git a/examples/type-inference.test.ts b/examples/type-inference.test.ts index 155875b..a9312a4 100644 --- a/examples/type-inference.test.ts +++ b/examples/type-inference.test.ts @@ -1,4 +1,4 @@ -// This file shows how to infer types from decoders. +// This file shows how to infer types from codecs. import { expectType, TypeEqual } from "ts-expect"; import { expect, test } from "vitest"; @@ -17,29 +17,35 @@ import { } from ".."; import { run } from "../tests/helpers"; -test("making a type from a decoder", () => { +test("making a type from a codec", () => { // Rather than first typing out a `type` for `Person` and then essentially - // typing the same thing again in the decoder (especially `fieldsAuto` decoders - // look almost identical to `type` they decode to!), you can start with the - // decoder and extract the type afterwards with tiny-decoder’s `Infer` utility. - const personDecoder = fieldsAuto({ + // typing the same thing again in the codec (especially `fieldsAuto` codecs + // look almost identical to the `type` they decode to!), you can start with the + // codec and extract the type afterwards with tiny-decoder’s `Infer` utility. + const personCodec = fieldsAuto({ name: string, age: number, }); // Hover over `Person` to see what it looks like! - type Person = Infer; + type Person = Infer; expectType>(true); // If it feels like you are specifying everything twice – once in a `type` or - // `interface`, and once in the decoder – you might find this `Infer` - // technique interesting. But this `Infer` approach you don’t have to - // write what your records look like “twice.” Personally I don’t always mind + // `interface`, and once in the codec – you might find this `Infer` + // technique interesting. With this `Infer` approach you don’t have to + // write what your objects look like “twice.” Personally I don’t always mind // the “duplication,” but when you do – try out the `Infer` approach! // Here’s a more complex example for trying out TypeScript’s inference. - const userDecoder = fieldsAuto({ - id: map(multi(["string", "number"]), ({ value }) => value), + const userCodec = fieldsAuto({ + id: map(multi(["string", "number"]), { + decoder: ({ value }) => value, + encoder: (value) => + typeof value === "string" + ? { type: "string", value } + : { type: "number", value }, + }), name: string, age: number, active: boolean, @@ -48,7 +54,7 @@ test("making a type from a decoder", () => { }); // Then, let TypeScript infer the `User` type! - type User = Infer; + type User = Infer; // Try hovering over `User` in the line above – your editor should reveal the // exact shape of the type. @@ -60,7 +66,7 @@ test("making a type from a decoder", () => { type: "user", }; - const userResult: DecoderResult = userDecoder(data); + const userResult: DecoderResult = userCodec.decoder(data); expect(userResult).toMatchInlineSnapshot(` { "tag": "Valid", @@ -123,9 +129,9 @@ test("making a type from an object and stringUnion", () => { expectType>(true); - const severityDecoder = stringUnion(SEVERITIES); - expectType>>(true); - expect(run(severityDecoder, "High")).toBe("High"); + const severityCodec = stringUnion(SEVERITIES); + expectType>>(true); + expect(run(severityCodec, "High")).toBe("High"); function coloredSeverity(severity: Severity): string { return chalk.hex(SEVERITY_COLORS[severity])(severity); diff --git a/examples/unknown.test.ts b/examples/unknown.test.ts deleted file mode 100644 index 62d354b..0000000 --- a/examples/unknown.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, test } from "vitest"; - -import { Decoder, fieldsAuto, string } from "../"; - -test("decoding unknown values", () => { - // Have a piece of data with a very generic field? - type Message = { - text: string; - data: unknown; - }; - - const message: unknown = { text: "Hello, world!", data: 15 }; - - const messageDecoder1: Decoder = fieldsAuto({ - text: string, - // All fields are already `unknown` so you can pass them through as-is. - data: (value) => ({ tag: "Valid", value }), - }); - expect(messageDecoder1(message)).toMatchInlineSnapshot(` - { - "tag": "Valid", - "value": { - "data": 15, - "text": "Hello, world!", - }, - } - `); - - // If you like, you can define this helper function: - const unknown: Decoder = (value) => ({ tag: "Valid", value }); - - const messageDecoder2: Decoder = fieldsAuto({ - text: string, - data: unknown, - }); - expect(messageDecoder2(message)).toMatchInlineSnapshot(` - { - "tag": "Valid", - "value": { - "data": 15, - "text": "Hello, world!", - }, - } - `); -}); diff --git a/examples/untagged-union.test.ts b/examples/untagged-union.test.ts index 29a1683..5355b08 100644 --- a/examples/untagged-union.test.ts +++ b/examples/untagged-union.test.ts @@ -3,10 +3,9 @@ import { expect, test } from "vitest"; import { array, boolean, - Decoder, + Codec, fieldsAuto, Infer, - map, number, string, undefinedOr, @@ -25,28 +24,32 @@ test("untagged union", () => { type UserResult = Failure | User; - const userDecoder = fieldsAuto({ + const userCodec = fieldsAuto({ name: string, followers: number, }); - const failureDecoder = fieldsAuto({ + const failureCodec = fieldsAuto({ error: string, errorCode: number, }); - const userResultDecoder: Decoder = (value) => - // This is a bit annoying to do. Prefer a tagged union and use `fieldsAuto`. - // But when that’s not possible, this is a simple way of “committing” to one - // of the union variants and choosing a decoder based on that. - // This approach results in much easier to understand error messages at - // runtime than an approach of first trying the first decoder, and then - // the second (because if both fail, you need to display both error messages). - typeof value === "object" && value !== null && "error" in value - ? failureDecoder(value) - : userDecoder(value); - - expect(userResultDecoder({ name: "John", followers: 42 })) + const userResultCodec: Codec = { + decoder: (value) => + // This is a bit annoying to do. Prefer a tagged union and use `fieldsAuto`. + // But when that’s not possible, this is a simple way of “committing” to one + // of the union variants and choosing a decoder based on that. + // This approach results in much easier to understand error messages at + // runtime than an approach of first trying the first decoder, and then + // the second (because if both fail, you need to display both error messages). + typeof value === "object" && value !== null && "error" in value + ? failureCodec.decoder(value) + : userCodec.decoder(value), + encoder: (value) => + "error" in value ? failureCodec.encoder(value) : userCodec.encoder(value), + }; + + expect(userResultCodec.decoder({ name: "John", followers: 42 })) .toMatchInlineSnapshot(` { "tag": "Valid", @@ -57,7 +60,15 @@ test("untagged union", () => { } `); - expect(userResultDecoder({ error: "Not found", errorCode: 404 })) + expect(userResultCodec.encoder({ name: "John", followers: 42 })) + .toMatchInlineSnapshot(` + { + "followers": 42, + "name": "John", + } + `); + + expect(userResultCodec.decoder({ error: "Not found", errorCode: 404 })) .toMatchInlineSnapshot(` { "tag": "Valid", @@ -67,47 +78,69 @@ test("untagged union", () => { }, } `); + + expect(userResultCodec.encoder({ error: "Not found", errorCode: 404 })) + .toMatchInlineSnapshot(` + { + "error": "Not found", + "errorCode": 404, + } + `); }); test("tagged union, but using boolean instead of string", () => { - const adminDecoder = map( - fieldsAuto({ - name: string, - access: array(string), - }), - (props) => ({ - isAdmin: true as const, - ...props, - }), - ); + function constant( + constantValue: T, + ): Codec { + return { + decoder: (value) => + value === constantValue + ? { tag: "Valid", value: constantValue } + : { + tag: "DecoderError", + error: { + tag: "custom", + message: `Expected ${JSON.stringify(constantValue)}`, + got: value, + path: [], + }, + }, + encoder: (value) => value, + }; + } - const notAdminDecoder = map( - fieldsAuto({ - name: string, - location: undefinedOr(string), - }), - (props) => ({ - isAdmin: false as const, - ...props, - }), - ); - - type User = Infer | Infer; - - const userDecoder: Decoder = (value) => { - const result = fieldsAuto({ isAdmin: boolean })(value); - switch (result.tag) { - case "DecoderError": - return result; - case "Valid": - return result.value.isAdmin - ? adminDecoder(value) - : notAdminDecoder(value); - } + const adminCodec = fieldsAuto({ + isAdmin: constant(true), + name: string, + access: array(string), + }); + + const notAdminCodec = fieldsAuto({ + isAdmin: constant(false), + name: string, + location: undefinedOr(string), + }); + + type User = Infer | Infer; + + const userCodec: Codec = { + decoder: (value) => { + const result = fieldsAuto({ isAdmin: boolean }).decoder(value); + switch (result.tag) { + case "DecoderError": + return result; + case "Valid": + return result.value.isAdmin + ? adminCodec.decoder(value) + : notAdminCodec.decoder(value); + } + }, + encoder: (value) => + value.isAdmin ? adminCodec.encoder(value) : notAdminCodec.encoder(value), }; expect( - userDecoder({ + userCodec.decoder({ isAdmin: true, name: "John", access: [], @@ -124,7 +157,21 @@ test("tagged union, but using boolean instead of string", () => { `); expect( - userDecoder({ + userCodec.encoder({ + isAdmin: true, + name: "John", + access: [], + }), + ).toMatchInlineSnapshot(` + { + "access": [], + "isAdmin": true, + "name": "John", + } + `); + + expect( + userCodec.decoder({ isAdmin: false, name: "Jane", location: undefined, @@ -139,4 +186,18 @@ test("tagged union, but using boolean instead of string", () => { }, } `); + + expect( + userCodec.encoder({ + isAdmin: false, + name: "Jane", + location: undefined, + }), + ).toMatchInlineSnapshot(` + { + "isAdmin": false, + "location": undefined, + "name": "Jane", + } + `); }); diff --git a/index.ts b/index.ts index 7c43bf2..db14621 100644 --- a/index.ts +++ b/index.ts @@ -2,74 +2,97 @@ // No `any` “leaks” when _using_ the library, though. /* eslint-disable @typescript-eslint/no-explicit-any */ -export type Decoder = (value: unknown) => DecoderResult; +export type Codec = { + decoder: (value: unknown) => DecoderResult; + encoder: (value: Decoded) => Encoded; +}; -export type DecoderResult = +export type DecoderResult = | { tag: "DecoderError"; error: DecoderError; } | { tag: "Valid"; - value: T; + value: Decoded; }; -export type Infer> = Extract< - ReturnType, +export type Infer> = Extract< + ReturnType, { tag: "Valid" } >["value"]; +export type InferEncoded> = ReturnType; + // Make VSCode show `{ a: string; b?: number }` instead of `{ a: string } & { b?: number }`. // https://stackoverflow.com/a/57683652/2010616 type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; -export function boolean(value: unknown): DecoderResult { - return typeof value === "boolean" - ? { tag: "Valid", value } - : { - tag: "DecoderError", - error: { tag: "boolean", got: value, path: [] }, - }; +function identity(value: T): T { + return value; } -export function number(value: unknown): DecoderResult { - return typeof value === "number" - ? { tag: "Valid", value } - : { - tag: "DecoderError", - error: { tag: "number", got: value, path: [] }, - }; -} +export const unknown: Codec = { + decoder: (value) => ({ tag: "Valid", value }), + encoder: identity, +}; -export function string(value: unknown): DecoderResult { - return typeof value === "string" - ? { tag: "Valid", value } - : { - tag: "DecoderError", - error: { tag: "string", got: value, path: [] }, - }; -} +export const boolean: Codec = { + decoder: (value) => + typeof value === "boolean" + ? { tag: "Valid", value } + : { + tag: "DecoderError", + error: { tag: "boolean", got: value, path: [] }, + }, + encoder: identity, +}; -export function stringUnion< - const T extends readonly [string, ...Array], ->(variants: T): Decoder { - return (value) => { - const stringResult = string(value); - if (stringResult.tag === "DecoderError") { - return stringResult; - } - const str = stringResult.value; - return variants.includes(str) - ? { tag: "Valid", value: str } +export const number: Codec = { + decoder: (value) => + typeof value === "number" + ? { tag: "Valid", value } : { tag: "DecoderError", - error: { - tag: "unknown stringUnion variant", - knownVariants: variants as unknown as Array, - got: str, - path: [], - }, - }; + error: { tag: "number", got: value, path: [] }, + }, + encoder: identity, +}; + +export const string: Codec = { + decoder: (value) => + typeof value === "string" + ? { tag: "Valid", value } + : { + tag: "DecoderError", + error: { tag: "string", got: value, path: [] }, + }, + encoder: identity, +}; + +export function stringUnion< + const Variants extends readonly [string, ...Array], +>(variants: Variants): Codec { + return { + decoder: (value) => { + const stringResult = string.decoder(value); + if (stringResult.tag === "DecoderError") { + return stringResult; + } + const str = stringResult.value; + return variants.includes(str) + ? { tag: "Valid", value: str } + : { + tag: "DecoderError", + error: { + tag: "unknown stringUnion variant", + knownVariants: variants as unknown as Array, + got: str, + path: [], + }, + }; + }, + encoder: identity, }; } @@ -91,70 +114,95 @@ function unknownRecord(value: unknown): DecoderResult> { }; } -export function array(decoder: Decoder): Decoder> { - return (value) => { - const arrResult = unknownArray(value); - if (arrResult.tag === "DecoderError") { - return arrResult; - } - const arr = arrResult.value; - const result = []; - for (let index = 0; index < arr.length; index++) { - const decoderResult = decoder(arr[index]); - switch (decoderResult.tag) { - case "DecoderError": - return { - tag: "DecoderError", - error: { - ...decoderResult.error, - path: [index, ...decoderResult.error.path], - }, - }; - case "Valid": - result.push(decoderResult.value); - break; +export function array( + codec: Codec, +): Codec, Array> { + return { + decoder: (value) => { + const arrResult = unknownArray(value); + if (arrResult.tag === "DecoderError") { + return arrResult; } - } - return { tag: "Valid", value: result }; + const arr = arrResult.value; + const result = []; + for (let index = 0; index < arr.length; index++) { + const decoderResult = codec.decoder(arr[index]); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + path: [index, ...decoderResult.error.path], + }, + }; + case "Valid": + result.push(decoderResult.value); + break; + } + } + return { tag: "Valid", value: result }; + }, + encoder: (arr) => { + const result = []; + for (const item of arr) { + result.push(codec.encoder(item)); + } + return result; + }, }; } -export function record(decoder: Decoder): Decoder> { - return (value) => { - const objectResult = unknownRecord(value); - if (objectResult.tag === "DecoderError") { - return objectResult; - } - const object = objectResult.value; - const keys = Object.keys(object); - const result: Record = {}; - - for (const key of keys) { - if (key === "__proto__") { - continue; +export function record( + codec: Codec, +): Codec, Record> { + return { + decoder: (value) => { + const objectResult = unknownRecord(value); + if (objectResult.tag === "DecoderError") { + return objectResult; } - const decoderResult = decoder(object[key]); - switch (decoderResult.tag) { - case "DecoderError": - return { - tag: "DecoderError", - error: { - ...decoderResult.error, - path: [key, ...decoderResult.error.path], - }, - }; - case "Valid": - result[key] = decoderResult.value; - break; + const object = objectResult.value; + const keys = Object.keys(object); + const result: Record = {}; + + for (const key of keys) { + if (key === "__proto__") { + continue; + } + const decoderResult = codec.decoder(object[key]); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + path: [key, ...decoderResult.error.path], + }, + }; + case "Valid": + result[key] = decoderResult.value; + break; + } } - } - return { tag: "Valid", value: result }; + return { tag: "Valid", value: result }; + }, + encoder: (object) => { + const result: Record = {}; + for (const [key, value] of Object.entries(object)) { + if (key === "__proto__") { + continue; + } + result[key] = codec.encoder(value); + } + return result; + }, }; } -type Field = Meta & { - decoder: Decoder; +type Field = Meta & { + codec: Codec; }; type FieldMeta = { @@ -163,15 +211,22 @@ type FieldMeta = { tag?: { decoded: string; encoded: string } | undefined; }; -type FieldsMapping = Record | Field>; +type FieldsMapping = Record | Field>; -type InferField | Field> = - T extends Field - ? Infer - : T extends Decoder +type InferField | Field> = + T extends Field + ? Infer + : T extends Codec ? Infer : never; +type InferEncodedField | Field> = + T extends Field + ? InferEncoded + : T extends Codec + ? InferEncoded + : never; + type InferFields = Expand< // eslint-disable-next-line @typescript-eslint/sort-type-constituents { @@ -185,95 +240,142 @@ type InferFields = Expand< } >; +type InferEncodedFields = Expand< + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + { + [Key in keyof Mapping as Mapping[Key] extends { optional: true } + ? never + : Mapping[Key] extends { renameFrom: infer Name } + ? Name extends string + ? Name + : Key + : Key]: InferEncodedField; + } & { + [Key in keyof Mapping as Mapping[Key] extends { optional: true } + ? Mapping[Key] extends { renameFrom: infer Name } + ? Name extends string + ? Name + : Key + : Key + : never]?: InferEncodedField; + } +>; + export function fieldsAuto( mapping: Mapping, { allowExtraFields = true }: { allowExtraFields?: boolean } = {}, -): Decoder> { - return (value) => { - const objectResult = unknownRecord(value); - if (objectResult.tag === "DecoderError") { - return objectResult; - } - const object = objectResult.value; - const keys = Object.keys(mapping); - const knownFields = new Set(); - const result: Record = {}; - - for (const key of keys) { - if (key === "__proto__") { - continue; - } - const fieldOrDecoder = mapping[key]; - const field_: Field = - "decoder" in fieldOrDecoder - ? fieldOrDecoder - : { decoder: fieldOrDecoder }; - const { - decoder, - renameFrom: encodedFieldName = key, - optional: isOptional = false, - } = field_; - if (encodedFieldName === "__proto__") { - continue; - } - knownFields.add(encodedFieldName); - if (!(encodedFieldName in object)) { - if (!isOptional) { - return { - tag: "DecoderError", - error: { - tag: "missing field", - field: encodedFieldName, - got: object, - path: [], - }, - }; +): Codec, InferEncodedFields> { + return { + decoder: (value) => { + const objectResult = unknownRecord(value); + if (objectResult.tag === "DecoderError") { + return objectResult; + } + const object = objectResult.value; + const keys = Object.keys(mapping); + const knownFields = new Set(); + const result: Record = {}; + + for (const key of keys) { + if (key === "__proto__") { + continue; + } + const fieldOrCodec = mapping[key]; + const field_: Field = + "codec" in fieldOrCodec ? fieldOrCodec : { codec: fieldOrCodec }; + const { + codec: { decoder }, + renameFrom: encodedFieldName = key, + optional: isOptional = false, + } = field_; + if (encodedFieldName === "__proto__") { + continue; + } + knownFields.add(encodedFieldName); + if (!(encodedFieldName in object)) { + if (!isOptional) { + return { + tag: "DecoderError", + error: { + tag: "missing field", + field: encodedFieldName, + got: object, + path: [], + }, + }; + } + continue; + } + const decoderResult = decoder(object[encodedFieldName]); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + path: [encodedFieldName, ...decoderResult.error.path], + }, + }; + case "Valid": + result[key] = decoderResult.value; + break; } - continue; } - const decoderResult = decoder(object[encodedFieldName]); - switch (decoderResult.tag) { - case "DecoderError": + + if (!allowExtraFields) { + const unknownFields = Object.keys(object).filter( + (key) => !knownFields.has(key), + ); + if (unknownFields.length > 0) { return { tag: "DecoderError", error: { - ...decoderResult.error, - path: [encodedFieldName, ...decoderResult.error.path], + tag: "exact fields", + knownFields: Array.from(knownFields), + got: unknownFields, + path: [], }, }; - case "Valid": - result[key] = decoderResult.value; - break; + } } - } - if (!allowExtraFields) { - const unknownFields = Object.keys(object).filter( - (key) => !knownFields.has(key), - ); - if (unknownFields.length > 0) { - return { - tag: "DecoderError", - error: { - tag: "exact fields", - knownFields: Array.from(knownFields), - got: unknownFields, - path: [], - }, - }; + return { tag: "Valid", value: result as InferFields }; + }, + encoder: (object) => { + const result: Record = {}; + for (const key of Object.keys(mapping)) { + if (key === "__proto__") { + continue; + } + const fieldOrCodec = mapping[key]; + const field_: Field = + "codec" in fieldOrCodec ? fieldOrCodec : { codec: fieldOrCodec }; + const { + codec: { encoder }, + renameFrom: encodedFieldName = key, + optional: isOptional = false, + } = field_; + if ( + encodedFieldName === "__proto__" || + (isOptional && !(key in object)) + ) { + continue; + } + const value = object[key as keyof InferFields]; + result[encodedFieldName] = encoder(value); } - } - - return { tag: "Valid", value: result as InferFields }; + return result as InferEncodedFields; + }, }; } -export function field>( - decoder: Decoder, - meta: Meta, -): Field { +export function field< + Decoded, + Encoded, + const Meta extends Omit, +>(codec: Codec, meta: Meta): Field { return { - decoder, + codec, ...meta, }; } @@ -281,37 +383,55 @@ export function field>( type InferFieldsUnion = MappingsUnion extends any ? InferFields : never; +type InferEncodedFieldsUnion = + MappingsUnion extends any ? InferEncodedFields : never; + type Variant = Record< DecodedCommonField, - Field + Field > & - Record | Field>; + Record | Field>; export function fieldsUnion< const DecodedCommonField extends keyof Variants[number], Variants extends readonly [ Variant, - ...ReadonlyArray>, + ...Array>, ], >( - decodedCommonField: DecodedCommonField, + decodedCommonField: keyof InferEncodedFieldsUnion< + Variants[number] + > extends never + ? [ + "fieldsUnion variants must have a field in common, and their encoded field names must be the same", + never, + ] + : DecodedCommonField, variants: Variants, { allowExtraFields = true }: { allowExtraFields?: boolean } = {}, -): Decoder> { +): Codec< + InferFieldsUnion, + InferEncodedFieldsUnion +> { if (decodedCommonField === "__proto__") { - throw new Error("fieldsUnion: commonField cannot be __proto__"); + throw new Error("fieldsUnion: decoded common field cannot be __proto__"); } - const decoderMap = new Map>(); // encodedName -> decoder + type VariantCodec = Codec; + const decoderMap = new Map(); // encodedName -> decoder + const encoderMap = new Map(); // decodedName -> encoder let maybeEncodedCommonField: number | string | symbol | undefined = undefined; for (const [index, variant] of variants.entries()) { const field_: Field< + any, any, FieldMeta & { tag: { decoded: string; encoded: string } } > = variant[decodedCommonField]; - const { renameFrom: encodedFieldName = decodedCommonField } = field_; + const { + renameFrom: encodedFieldName = decodedCommonField as DecodedCommonField, + } = field_; if (maybeEncodedCommonField === undefined) { maybeEncodedCommonField = encodedFieldName; } else if (maybeEncodedCommonField !== encodedFieldName) { @@ -323,8 +443,9 @@ export function fieldsUnion< )}) than before (${JSON.stringify(maybeEncodedCommonField)}).`, ); } - const fullDecoder = fieldsAuto(variant, { allowExtraFields }); - decoderMap.set(field_.tag.encoded, fullDecoder); + const fullCodec = fieldsAuto(variant, { allowExtraFields }); + decoderMap.set(field_.tag.encoded, fullCodec.decoder); + encoderMap.set(field_.tag.decoded, fullCodec.encoder); } if (typeof maybeEncodedCommonField !== "string") { @@ -337,40 +458,56 @@ export function fieldsUnion< const encodedCommonField = maybeEncodedCommonField; - return (value) => { - const encodedNameResult = fieldsAuto({ [encodedCommonField]: string })( - value, - ); - if (encodedNameResult.tag === "DecoderError") { - return encodedNameResult; - } - const encodedName = encodedNameResult.value[encodedCommonField]; - const decoder = decoderMap.get(encodedName); - if (decoder === undefined) { - return { - tag: "DecoderError", - error: { - tag: "unknown fieldsUnion tag", - knownTags: Array.from(decoderMap.keys()), - got: encodedName, - path: [encodedCommonField], - }, - }; - } - return decoder(value); + return { + decoder: (value) => { + const encodedNameResult = fieldsAuto({ + [encodedCommonField]: string, + }).decoder(value); + if (encodedNameResult.tag === "DecoderError") { + return encodedNameResult; + } + const encodedName = encodedNameResult.value[encodedCommonField]; + const decoder = decoderMap.get(encodedName); + if (decoder === undefined) { + return { + tag: "DecoderError", + error: { + tag: "unknown fieldsUnion tag", + knownTags: Array.from(decoderMap.keys()), + got: encodedName, + path: [encodedCommonField], + }, + }; + } + return decoder(value); + }, + encoder: (value) => { + const decodedName = (value as Record)[ + decodedCommonField as DecodedCommonField + ]; + const encoder = encoderMap.get(decodedName); + if (encoder === undefined) { + throw new Error( + `fieldsUnion: Unexpectedly found no encoder for decoded variant name: ${JSON.stringify( + decodedName, + )} at key ${JSON.stringify(decodedCommonField)}`, + ); + } + return encoder(value) as InferEncodedFieldsUnion; + }, }; } export function tag( decoded: Decoded, -): Field; +): Field; export function tag( decoded: Decoded, options: { renameTagFrom: Encoded; }, -): Field; +): Field; export function tag< const Decoded extends string, @@ -381,6 +518,7 @@ export function tag< renameFieldFrom: EncodedFieldName; }, ): Field< + Decoded, Decoded, { renameFrom: EncodedFieldName; tag: { decoded: string; encoded: string } } >; @@ -397,6 +535,7 @@ export function tag< }, ): Field< Decoded, + Encoded, { renameFrom: EncodedFieldName; tag: { decoded: string; encoded: string } } >; @@ -415,74 +554,96 @@ export function tag< } = {}, ): Field< Decoded, + Encoded, { renameFrom: EncodedFieldName | undefined; tag: { decoded: string; encoded: string }; } > { return { - decoder: (value) => { - const strResult = string(value); - if (strResult.tag === "DecoderError") { - return strResult; - } - const str = strResult.value; - return str === encoded - ? { tag: "Valid", value: decoded } - : { - tag: "DecoderError", - error: { - tag: "wrong tag", - expected: encoded, - got: str, - path: [], - }, - }; + codec: { + decoder: (value) => { + const strResult = string.decoder(value); + if (strResult.tag === "DecoderError") { + return strResult; + } + const str = strResult.value; + return str === encoded + ? { tag: "Valid", value: decoded } + : { + tag: "DecoderError", + error: { + tag: "wrong tag", + expected: encoded, + got: str, + path: [], + }, + }; + }, + encoder: () => encoded, }, renameFrom: encodedFieldName, tag: { decoded, encoded }, }; } -export function tuple>( - mapping: [...{ [P in keyof T]: Decoder }], -): Decoder { - return (value) => { - const arrResult = unknownArray(value); - if (arrResult.tag === "DecoderError") { - return arrResult; - } - const arr = arrResult.value; - if (arr.length !== mapping.length) { - return { - tag: "DecoderError", - error: { - tag: "tuple size", - expected: mapping.length, - got: arr.length, - path: [], - }, - }; - } - const result = []; - for (let index = 0; index < arr.length; index++) { - const decoder = mapping[index]; - const decoderResult = decoder(arr[index]); - switch (decoderResult.tag) { - case "DecoderError": - return { - tag: "DecoderError", - error: { - ...decoderResult.error, - path: [index, ...decoderResult.error.path], - }, - }; - case "Valid": - result.push(decoderResult.value); - break; +type InferTuple>> = [ + ...{ [P in keyof Codecs]: Infer }, +]; + +type InferEncodedTuple>> = [ + ...{ [P in keyof Codecs]: InferEncoded }, +]; + +export function tuple>>( + codecs: Codecs, +): Codec, InferEncodedTuple> { + return { + decoder: (value) => { + const arrResult = unknownArray(value); + if (arrResult.tag === "DecoderError") { + return arrResult; } - } - return { tag: "Valid", value: result as T }; + const arr = arrResult.value; + if (arr.length !== codecs.length) { + return { + tag: "DecoderError", + error: { + tag: "tuple size", + expected: codecs.length, + got: arr.length, + path: [], + }, + }; + } + const result = []; + for (let index = 0; index < arr.length; index++) { + const codec = codecs[index]; + const decoderResult = codec.decoder(arr[index]); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + path: [index, ...decoderResult.error.path], + }, + }; + case "Valid": + result.push(decoderResult.value); + break; + } + } + return { tag: "Valid", value: result as InferTuple }; + }, + encoder: (value) => { + const result = []; + for (let index = 0; index < codecs.length; index++) { + const codec = codecs[index]; + result.push(codec.encoder(value[index])); + } + return result as InferEncodedTuple; + }, }; } @@ -515,155 +676,187 @@ type MultiTypeName = export function multi< Types extends readonly [MultiTypeName, ...Array], ->(types: Types): Decoder> { - return (value) => { - if (value === undefined) { - if (types.includes("undefined")) { - return { - tag: "Valid", - value: { type: "undefined", value } as unknown as Multi< - Types[number] - >, - }; - } - } else if (value === null) { - if (types.includes("null")) { - return { - tag: "Valid", - value: { type: "null", value } as unknown as Multi, - }; - } - } else if (typeof value === "boolean") { - if (types.includes("boolean")) { - return { - tag: "Valid", - value: { type: "boolean", value } as unknown as Multi, - }; - } - } else if (typeof value === "number") { - if (types.includes("number")) { - return { - tag: "Valid", - value: { type: "number", value } as unknown as Multi, - }; - } - } else if (typeof value === "string") { - if (types.includes("string")) { - return { - tag: "Valid", - value: { type: "string", value } as unknown as Multi, - }; - } - } else if (Array.isArray(value)) { - if (types.includes("array")) { - return { - tag: "Valid", - value: { type: "array", value } as unknown as Multi, - }; - } - } else { - if (types.includes("object")) { - return { - tag: "Valid", - value: { type: "object", value } as unknown as Multi, - }; +>(types: Types): Codec, Multi["value"]> { + return { + decoder: (value) => { + if (value === undefined) { + if (types.includes("undefined")) { + return { + tag: "Valid", + value: { type: "undefined", value } as unknown as Multi< + Types[number] + >, + }; + } + } else if (value === null) { + if (types.includes("null")) { + return { + tag: "Valid", + value: { type: "null", value } as unknown as Multi, + }; + } + } else if (typeof value === "boolean") { + if (types.includes("boolean")) { + return { + tag: "Valid", + value: { type: "boolean", value } as unknown as Multi< + Types[number] + >, + }; + } + } else if (typeof value === "number") { + if (types.includes("number")) { + return { + tag: "Valid", + value: { type: "number", value } as unknown as Multi, + }; + } + } else if (typeof value === "string") { + if (types.includes("string")) { + return { + tag: "Valid", + value: { type: "string", value } as unknown as Multi, + }; + } + } else if (Array.isArray(value)) { + if (types.includes("array")) { + return { + tag: "Valid", + value: { type: "array", value } as unknown as Multi, + }; + } + } else { + if (types.includes("object")) { + return { + tag: "Valid", + value: { type: "object", value } as unknown as Multi, + }; + } } - } - return { - tag: "DecoderError", - error: { - tag: "unknown multi type", - knownTypes: types as unknown as Array<"undefined">, // Type checking hack. - got: value, - path: [], - }, - }; + return { + tag: "DecoderError", + error: { + tag: "unknown multi type", + knownTypes: types as unknown as Array<"undefined">, // Type checking hack. + got: value, + path: [], + }, + }; + }, + encoder: (value) => value.value, }; } -export function recursive(callback: () => Decoder): Decoder { - return (value) => callback()(value); +export function recursive( + callback: () => Codec, +): Codec { + return { + decoder: (value) => callback().decoder(value), + encoder: (value) => callback().encoder(value), + }; } -export function undefinedOr(decoder: Decoder): Decoder { - return (value) => { - if (value === undefined) { - return { tag: "Valid", value: undefined }; - } - const decoderResult = decoder(value); - switch (decoderResult.tag) { - case "DecoderError": - return decoderResult.error.path.length === 0 - ? { - tag: "DecoderError", - error: { - ...decoderResult.error, - orExpected: - decoderResult.error.orExpected === "null" - ? "null or undefined" - : "undefined", - }, - } - : decoderResult; - case "Valid": - return decoderResult; - } +export function undefinedOr( + codec: Codec, +): Codec { + return { + decoder: (value) => { + if (value === undefined) { + return { tag: "Valid", value: undefined }; + } + const decoderResult = codec.decoder(value); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + orExpected: + decoderResult.error.orExpected === "null" + ? "null or undefined" + : "undefined", + }, + }; + case "Valid": + return decoderResult; + } + }, + encoder: (value) => + value === undefined ? undefined : codec.encoder(value), }; } -export function nullable(decoder: Decoder): Decoder { - return (value) => { - if (value === null) { - return { tag: "Valid", value: null }; - } - const decoderResult = decoder(value); - switch (decoderResult.tag) { - case "DecoderError": - return decoderResult.error.path.length === 0 - ? { - tag: "DecoderError", - error: { - ...decoderResult.error, - orExpected: - decoderResult.error.orExpected === "undefined" - ? "null or undefined" - : "null", - }, - } - : decoderResult; - case "Valid": - return decoderResult; - } +export function nullable( + codec: Codec, +): Codec { + return { + decoder: (value) => { + if (value === null) { + return { tag: "Valid", value: null }; + } + const decoderResult = codec.decoder(value); + switch (decoderResult.tag) { + case "DecoderError": + return { + tag: "DecoderError", + error: { + ...decoderResult.error, + orExpected: + decoderResult.error.orExpected === "undefined" + ? "null or undefined" + : "null", + }, + }; + case "Valid": + return decoderResult; + } + }, + encoder: (value) => (value === null ? null : codec.encoder(value)), }; } -export function map( - decoder: Decoder, - transform: (value: T) => U, -): Decoder { - return (value) => { - const decoderResult = decoder(value); - switch (decoderResult.tag) { - case "DecoderError": - return decoderResult; - case "Valid": - return { tag: "Valid", value: transform(decoderResult.value) }; - } +export function map( + codec: Codec, + transform: { + decoder: (value: Decoded) => NewDecoded; + encoder: (value: NewDecoded) => Readonly; + }, +): Codec { + return { + decoder: (value) => { + const decoderResult = codec.decoder(value); + switch (decoderResult.tag) { + case "DecoderError": + return decoderResult; + case "Valid": + return { + tag: "Valid", + value: transform.decoder(decoderResult.value), + }; + } + }, + encoder: (value) => codec.encoder(transform.encoder(value)), }; } -export function flatMap( - decoder: Decoder, - transform: (value: T) => DecoderResult, -): Decoder { - return (value) => { - const decoderResult = decoder(value); - switch (decoderResult.tag) { - case "DecoderError": - return decoderResult; - case "Valid": - return transform(decoderResult.value); - } +export function flatMap( + codec: Codec, + transform: { + decoder: (value: Decoded) => DecoderResult; + encoder: (value: NewDecoded) => Readonly; + }, +): Codec { + return { + decoder: (value) => { + const decoderResult = codec.decoder(value); + switch (decoderResult.tag) { + case "DecoderError": + return decoderResult; + case "Valid": + return transform.decoder(decoderResult.value); + } + }, + encoder: (value) => codec.encoder(transform.encoder(value)), }; } diff --git a/tests/decoders.test.ts b/tests/decoders.test.ts index 6c16924..ab94d0e 100644 --- a/tests/decoders.test.ts +++ b/tests/decoders.test.ts @@ -4,13 +4,14 @@ import { describe, expect, test } from "vitest"; import { array, boolean, - Decoder, + Codec, DecoderResult, field, fieldsAuto, fieldsUnion, flatMap, Infer, + InferEncoded, map, multi, nullable, @@ -22,16 +23,40 @@ import { tag, tuple, undefinedOr, + unknown, } from ".."; import { run } from "./helpers"; +test("unknown", () => { + expect(run(unknown, true)).toBe(true); + expect(run(unknown, 1)).toBe(1); + expect(run(unknown, { prop: 1 })).toStrictEqual({ prop: 1 }); + + expect(unknown.encoder(true)).toBe(true); + expect(unknown.encoder(1)).toBe(1); + expect(unknown.encoder({ prop: 1 })).toStrictEqual({ prop: 1 }); + + expectType>(unknown.decoder(true)); + expectType(unknown.encoder(true)); + + // @ts-expect-error Expected 1 arguments, but got 2. + unknown.decoder(true, []); +}); + test("boolean", () => { expect(run(boolean, true)).toBe(true); expect(run(boolean, false)).toBe(false); - expectType>(boolean(true)); + expect(boolean.encoder(true)).toBe(true); + expect(boolean.encoder(false)).toBe(false); + + expectType>(boolean.decoder(true)); + expectType(boolean.encoder(true)); + // @ts-expect-error Expected 1 arguments, but got 2. - boolean(true, []); + boolean.decoder(true, []); + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'boolean'. + boolean.encoder(0); expect(run(boolean, 0)).toMatchInlineSnapshot(` At root: @@ -47,9 +72,19 @@ test("number", () => { expect(run(number, Infinity)).toBe(Infinity); expect(run(number, -Infinity)).toBe(-Infinity); - expectType>(number(0)); + expect(number.encoder(0)).toBe(0); + expect(number.encoder(Math.PI)).toBe(3.141592653589793); + expect(number.encoder(NaN)).toBeNaN(); + expect(number.encoder(Infinity)).toBe(Infinity); + expect(number.encoder(-Infinity)).toBe(-Infinity); + + expectType>(number.decoder(0)); + expectType(number.encoder(0)); + // @ts-expect-error Expected 1 arguments, but got 2. - number(0, []); + number.decoder(0, []); + // @ts-expect-error Argument of type 'boolean' is not assignable to parameter of type 'number'. + number.encoder(true); expect(run(number, undefined)).toMatchInlineSnapshot(` At root: @@ -62,9 +97,16 @@ test("string", () => { expect(run(string, "")).toBe(""); expect(run(string, "string")).toBe("string"); - expectType>(string("")); + expect(string.encoder("")).toBe(""); + expect(string.encoder("string")).toBe("string"); + + expectType>(string.decoder("")); + expectType(string.encoder("")); + // @ts-expect-error Expected 1 arguments, but got 2. - string("", []); + string.decoder("", []); + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'. + string.encoder(0); expect(run(string, Symbol("desc"))).toMatchInlineSnapshot(` At root: @@ -75,10 +117,11 @@ test("string", () => { describe("stringUnion", () => { test("basic", () => { - type Color = Infer; - const colorDecoder = stringUnion(["red", "green", "blue"]); + type Color = Infer; + const Color = stringUnion(["red", "green", "blue"]); expectType>(true); + expectType>>(true); const red: Color = "red"; void red; @@ -87,15 +130,23 @@ describe("stringUnion", () => { const yellow: Color = "yellow"; void yellow; - expect(run(colorDecoder, "red")).toBe("red"); - expect(run(colorDecoder, "green")).toBe("green"); - expect(run(colorDecoder, "blue")).toBe("blue"); + expect(run(Color, "red")).toBe("red"); + expect(run(Color, "green")).toBe("green"); + expect(run(Color, "blue")).toBe("blue"); + + expect(Color.encoder("red")).toBe("red"); + expect(Color.encoder("green")).toBe("green"); + expect(Color.encoder("blue")).toBe("blue"); + + expectType>(Color.decoder("red")); + expectType(Color.encoder("red")); - expectType>(colorDecoder("red")); // @ts-expect-error Argument of type '{ one: null; two: null; }' is not assignable to parameter of type 'readonly string[]'. stringUnion({ one: null, two: null }); + // @ts-expect-error Argument of type '"magenta"' is not assignable to parameter of type '"red" | "green" | "blue"'. + Color.encoder("magenta"); - expect(run(colorDecoder, "Red")).toMatchInlineSnapshot(` + expect(run(Color, "Red")).toMatchInlineSnapshot(` At root: Expected one of these variants: "red", @@ -104,7 +155,7 @@ describe("stringUnion", () => { Got: "Red" `); - expect(run(colorDecoder, 0)).toMatchInlineSnapshot(` + expect(run(Color, 0)).toMatchInlineSnapshot(` At root: Expected a string Got: 0 @@ -114,27 +165,32 @@ describe("stringUnion", () => { test("empty array is not allowed", () => { // @ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [string, ...string[]]'. // Source has 0 element(s) but target requires 1. - const emptyDecoder = stringUnion([]); - expect(run(emptyDecoder, "test")).toMatchInlineSnapshot(` + const emptyCodec = stringUnion([]); + + expect(run(emptyCodec, "test")).toMatchInlineSnapshot(` At root: Expected one of these variants: (none) Got: "test" `); + + // Would have been cool if this was a TypeScript error due to `never`, + // but it’s good enough with having an error at the definition. + expect(emptyCodec.encoder("test")).toBe("test"); }); test("variants must be strings", () => { // @ts-expect-error Type 'number' is not assignable to type 'string'. stringUnion([1]); - const goodDecoder = stringUnion(["1"]); - expectType, "1">>(true); - expect(run(goodDecoder, "1")).toBe("1"); + const goodCodec = stringUnion(["1"]); + expectType, "1">>(true); + expect(run(goodCodec, "1")).toBe("1"); }); test("always print the expected tags in full", () => { - const decoder = stringUnion(["PrettyLongTagName1", "PrettyLongTagName2"]); + const codec = stringUnion(["PrettyLongTagName1", "PrettyLongTagName2"]); expect( - run(decoder, "PrettyLongTagNameButWrong", { + run(codec, "PrettyLongTagNameButWrong", { maxLength: 8, maxArrayChildren: 1, indent: " ".repeat(8), @@ -151,22 +207,32 @@ describe("stringUnion", () => { describe("array", () => { test("basic", () => { - type Bits = Infer; - const bitsDecoder = array(stringUnion(["0", "1"])); + type Bits = Infer; + const Bits = array(stringUnion(["0", "1"])); expectType>>(true); - expectType>(bitsDecoder([])); + expectType>>(true); + + expectType>(Bits.decoder([])); + expectType(Bits.encoder([])); + + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '("0" | "1")[]'. + Bits.encoder("0"); + + expect(run(Bits, [])).toStrictEqual([]); + expect(run(Bits, ["0"])).toStrictEqual(["0"]); + expect(run(Bits, ["0", "1", "1", "0"])).toStrictEqual(["0", "1", "1", "0"]); - expect(run(bitsDecoder, [])).toStrictEqual([]); - expect(run(bitsDecoder, ["0"])).toStrictEqual(["0"]); - expect(run(bitsDecoder, ["0", "1", "1", "0"])).toStrictEqual([ + expect(Bits.encoder([])).toStrictEqual([]); + expect(Bits.encoder(["0"])).toStrictEqual(["0"]); + expect(Bits.encoder(["0", "1", "1", "0"])).toStrictEqual([ "0", "1", "1", "0", ]); - expect(run(bitsDecoder, ["0", "2"])).toMatchInlineSnapshot(` + expect(run(Bits, ["0", "2"])).toMatchInlineSnapshot(` At root[1]: Expected one of these variants: "0", @@ -187,23 +253,54 @@ describe("array", () => { Got: Int32Array `); }); + + test("holes", () => { + const codec = array(undefinedOr(number)); + + const arr = []; + arr[0] = 1; + arr[2] = 3; + + expect(run(codec, arr)).toStrictEqual([1, undefined, 3]); + expect(codec.encoder(arr)).toStrictEqual([1, undefined, 3]); + }); }); describe("record", () => { test("basic", () => { - type Registers = Infer; - const registersDecoder = record(stringUnion(["0", "1"])); + type Registers = Infer; + const Registers = record(stringUnion(["0", "1"])); expectType>>(true); - expectType>(registersDecoder({})); + expectType>>(true); - expect(run(registersDecoder, {})).toStrictEqual({}); - expect(run(registersDecoder, { a: "0" })).toStrictEqual({ a: "0" }); - expect( - run(registersDecoder, { a: "0", b: "1", c: "1", d: "0" }), - ).toStrictEqual({ a: "0", b: "1", c: "1", d: "0" }); + expectType>(Registers.decoder({})); + expectType(Registers.encoder({})); + + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'Record'. + Registers.encoder("0"); - expect(run(registersDecoder, { a: "0", b: "2" })).toMatchInlineSnapshot(` + expect(run(Registers, {})).toStrictEqual({}); + expect(run(Registers, { a: "0" })).toStrictEqual({ a: "0" }); + expect(run(Registers, { a: "0", b: "1", c: "1", d: "0" })).toStrictEqual({ + a: "0", + b: "1", + c: "1", + d: "0", + }); + + expect(Registers.encoder({})).toStrictEqual({}); + expect(Registers.encoder({ a: "0" })).toStrictEqual({ a: "0" }); + expect(Registers.encoder({ a: "0", b: "1", c: "1", d: "0" })).toStrictEqual( + { + a: "0", + b: "1", + c: "1", + d: "0", + }, + ); + + expect(run(Registers, { a: "0", b: "2" })).toMatchInlineSnapshot(` At root["b"]: Expected one of these variants: "0", @@ -221,36 +318,55 @@ describe("record", () => { }); test("keys to regex", () => { - const decoder = flatMap(record(string), (items) => { - const result: Array<[RegExp, string]> = []; - for (const [key, value] of Object.entries(items)) { - try { - result.push([RegExp(key, "u"), value]); - } catch (error) { - return { - tag: "DecoderError", - error: { - tag: "custom", - message: error instanceof Error ? error.message : String(error), - got: key, - path: [key], - }, - }; + const codec = flatMap(record(string), { + decoder: (items) => { + const result: Array<[RegExp, string]> = []; + for (const [key, value] of Object.entries(items)) { + try { + result.push([RegExp(key, "u"), value]); + } catch (error) { + return { + tag: "DecoderError", + error: { + tag: "custom", + message: error instanceof Error ? error.message : String(error), + got: key, + path: [key], + }, + }; + } } - } - return { tag: "Valid", value: result }; + return { tag: "Valid", value: result }; + }, + encoder: (regexes) => + Object.fromEntries( + regexes.map(([regex, value]) => [regex.source, value]), + ), }); - expectType, Array<[RegExp, string]>>>(true); + expectType, Array<[RegExp, string]>>>(true); + expectType, Record>>( + true, + ); + + // @ts-expect-error Argument of type '{}' is not assignable to parameter of type '[RegExp, string][]'. + expect(() => codec.encoder({})).toThrow(); const good = { "\\d{4}:\\d{2}": "Year/month", ".*": "Rest" }; const bad = { "\\d{4}:\\d{2": "Year/month", ".*": "Rest" }; - expect(run(decoder, good)).toStrictEqual([ + expect(run(codec, good)).toStrictEqual([ [/\d{4}:\d{2}/u, "Year/month"], [/.*/u, "Rest"], ]); + expect( + codec.encoder([ + [/\d{4}:\d{2}/u, "Year/month"], + [/.*/u, "Rest"], + ]), + ).toStrictEqual(good); + // To avoid slightly different error messages on different Node.js versions. const cleanRegexError = (message: T | string): T | string => typeof message === "string" @@ -260,7 +376,7 @@ describe("record", () => { ) : message; - expect(cleanRegexError(run(decoder, bad))).toMatchInlineSnapshot( + expect(cleanRegexError(run(codec, bad))).toMatchInlineSnapshot( ` At root["\\\\d{4}:\\\\d{2"]: Invalid regular expression: (the regex error) @@ -269,7 +385,7 @@ describe("record", () => { ); expect( - cleanRegexError(run(fieldsAuto({ regexes: decoder }), { regexes: bad })), + cleanRegexError(run(fieldsAuto({ regexes: codec }), { regexes: bad })), ).toMatchInlineSnapshot(` At root["regexes"]["\\\\d{4}:\\\\d{2"]: Invalid regular expression: (the regex error) @@ -281,39 +397,58 @@ describe("record", () => { expect( run(record(number), JSON.parse(`{"a": 1, "__proto__": 2, "b": 3}`)), ).toStrictEqual({ a: 1, b: 3 }); + + expect( + record(number).encoder( + JSON.parse(`{"a": 1, "__proto__": 2, "b": 3}`) as Record< + string, + number + >, + ), + ).toStrictEqual({ a: 1, b: 3 }); }); }); describe("fieldsAuto", () => { - // @ts-expect-error Argument of type '((value: unknown) => string)[]' is not assignable to parameter of type 'FieldsMapping'. + // @ts-expect-error Argument of type 'Codec[]' is not assignable to parameter of type 'FieldsMapping'. + // Index signature for type 'string' is missing in type 'Codec[]'. fieldsAuto([string]); test("basic", () => { - type Person = Infer; - const personDecoder = fieldsAuto({ + type Person = Infer; + const Person = fieldsAuto({ id: number, firstName: string, }); expectType>(true); + expectType>>(true); + expectType>( - personDecoder({ id: 1, firstName: "John" }), + Person.decoder({ id: 1, firstName: "John" }), ); + expectType(Person.encoder({ id: 1, firstName: "John" })); - expect(run(personDecoder, { id: 1, firstName: "John" })).toStrictEqual({ + // @ts-expect-error Property 'firstName' is missing in type '{ id: number; }' but required in type '{ id: number; firstName: string; }'. + Person.encoder({ id: 1 }); + + expect(run(Person, { id: 1, firstName: "John" })).toStrictEqual({ id: 1, firstName: "John", }); - expect(run(personDecoder, { id: "1", firstName: "John" })) - .toMatchInlineSnapshot(` + expect(Person.encoder({ id: 1, firstName: "John" })).toStrictEqual({ + id: 1, + firstName: "John", + }); + + expect(run(Person, { id: "1", firstName: "John" })).toMatchInlineSnapshot(` At root["id"]: Expected a number Got: "1" `); - expect(run(personDecoder, { id: 1, first_name: "John" })) - .toMatchInlineSnapshot(` + expect(run(Person, { id: 1, first_name: "John" })).toMatchInlineSnapshot(` At root: Expected an object with a field called: "firstName" Got: { @@ -332,8 +467,8 @@ describe("fieldsAuto", () => { }); test("optional and renamed fields", () => { - type Person = Infer; - const personDecoder = fieldsAuto({ + type Person = Infer; + const Person = fieldsAuto({ id: number, firstName: field(string, { renameFrom: "first_name" }), lastName: field(string, { renameFrom: "last_name", optional: true }), @@ -359,12 +494,34 @@ describe("fieldsAuto", () => { } > >(true); + expectType< + TypeEqual< + InferEncoded, + { + id: number; + first_name: string; + last_name?: string; + age?: number; + likes?: number | undefined; + followers: number | undefined; + } + > + >(true); + expectType>( - personDecoder({ id: 1, first_name: "John", followers: undefined }), + Person.decoder({ id: 1, first_name: "John", followers: undefined }), ); + expectType<{ + id: number; + first_name: string; + followers: number | undefined; + }>(Person.encoder({ id: 1, firstName: "John", followers: undefined })); + + // @ts-expect-error Object literal may only specify known properties, but 'first_name' does not exist in type '{ id: number; firstName: string; followers: number | undefined; lastName?: string; age?: number; likes?: number | undefined; }'. Did you mean to write 'firstName'? + Person.encoder({ id: 1, first_name: "John", followers: undefined }); expect( - run(personDecoder, { + run(Person, { id: 1, firstName: "John", followers: undefined, @@ -380,7 +537,7 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + run(Person, { id: 1, first_name: false, followers: undefined, @@ -392,7 +549,7 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + run(Person, { id: 1, first_name: "John", followers: undefined, @@ -406,7 +563,21 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + Person.encoder({ + id: 1, + firstName: "John", + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "first_name": "John", + "followers": undefined, + "id": 1, + } + `); + + expect( + run(Person, { id: 1, first_name: "John", lastName: "Doe", @@ -421,7 +592,21 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + Person.encoder({ + id: 1, + firstName: "John", + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "first_name": "John", + "followers": undefined, + "id": 1, + } + `); + + expect( + run(Person, { id: 1, first_name: "John", last_name: "Doe", @@ -437,7 +622,23 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + Person.encoder({ + id: 1, + firstName: "John", + lastName: "Doe", + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "first_name": "John", + "followers": undefined, + "id": 1, + "last_name": "Doe", + } + `); + + expect( + run(Person, { id: 1, first_name: "John", age: 42, @@ -453,7 +654,23 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + Person.encoder({ + id: 1, + firstName: "John", + age: 42, + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "age": 42, + "first_name": "John", + "followers": undefined, + "id": 1, + } + `); + + expect( + run(Person, { id: 1, first_name: "John", age: undefined, @@ -466,7 +683,7 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + run(Person, { id: 1, first_name: "John", likes: 42, @@ -482,7 +699,23 @@ describe("fieldsAuto", () => { `); expect( - run(personDecoder, { + Person.encoder({ + id: 1, + firstName: "John", + likes: 42, + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "first_name": "John", + "followers": undefined, + "id": 1, + "likes": 42, + } + `); + + expect( + run(Person, { id: 1, first_name: "John", likes: undefined, @@ -496,6 +729,22 @@ describe("fieldsAuto", () => { "likes": undefined, } `); + + expect( + Person.encoder({ + id: 1, + firstName: "John", + likes: undefined, + followers: undefined, + }), + ).toMatchInlineSnapshot(` + { + "first_name": "John", + "followers": undefined, + "id": 1, + "likes": undefined, + } + `); }); describe("allowExtraFields", () => { @@ -508,12 +757,20 @@ describe("fieldsAuto", () => { four: {}, }), ).toStrictEqual({ one: "a", two: true }); + expect( run( fieldsAuto({ one: string, two: boolean }, { allowExtraFields: true }), { one: "a", two: true, three: 3, four: {} }, ), ).toStrictEqual({ one: "a", two: true }); + + fieldsAuto({ one: string, two: boolean }).encoder({ + one: "", + two: true, + // @ts-expect-error Object literal may only specify known properties, and 'three' does not exist in type '{ one: string; two: boolean; }'. + three: 1, + }); }); test("fail on excess properties", () => { @@ -539,6 +796,17 @@ describe("fieldsAuto", () => { "three", "four" `); + + fieldsAuto( + { one: string, two: boolean }, + { allowExtraFields: false }, + ).encoder({ + one: "a", + two: true, + // @ts-expect-error Object literal may only specify known properties, and 'three' does not exist in type '{ one: string; two: boolean; }'. + three: 3, + four: {}, + }); }); test("large number of excess properties", () => { @@ -566,7 +834,7 @@ describe("fieldsAuto", () => { }); test("always print the expected keys in full", () => { - const decoder = fieldsAuto( + const codec = fieldsAuto( { PrettyLongTagName1: string, PrettyLongTagName2: string, @@ -576,7 +844,7 @@ describe("fieldsAuto", () => { expect( run( - decoder, + codec, { PrettyLongTagName1: "", PrettyLongTagName2: "", @@ -600,36 +868,55 @@ describe("fieldsAuto", () => { }); test("__proto__ is not allowed", () => { - const decoder = fieldsAuto({ a: number, __proto__: string, b: number }); + const codec = fieldsAuto({ a: number, __proto__: string, b: number }); + expect( + run(codec, JSON.parse(`{"a": 1, "__proto__": "a", "b": 3}`)), + ).toStrictEqual({ a: 1, b: 3 }); expect( - run(decoder, JSON.parse(`{"a": 1, "__proto__": "a", "b": 3}`)), + codec.encoder( + JSON.parse(`{"a": 1, "__proto__": "a", "b": 3}`) as Infer, + ), ).toStrictEqual({ a: 1, b: 3 }); - const desc = Object.create(null) as { __proto__: Decoder }; + const desc = Object.create(null) as { __proto__: Codec }; desc.__proto__ = string; - const decoder2 = fieldsAuto(desc); - expect(run(decoder2, JSON.parse(`{"__proto__": "a"}`))).toStrictEqual({}); + const codec2 = fieldsAuto(desc); + expect(run(codec2, JSON.parse(`{"__proto__": "a"}`))).toStrictEqual({}); + expect( + codec2.encoder(JSON.parse(`{"__proto__": "a"}`) as Infer), + ).toStrictEqual({}); }); test("renaming from __proto__ is not allowed", () => { - const decoder = fieldsAuto({ + const codec = fieldsAuto({ a: number, b: field(string, { renameFrom: "__proto__" }), }); - expect( - run(decoder, JSON.parse(`{"a": 1, "__proto__": "a"}`)), - ).toStrictEqual({ a: 1 }); + expect(run(codec, JSON.parse(`{"a": 1, "__proto__": "a"}`))).toStrictEqual({ + a: 1, + }); + expect(codec.encoder({ a: 0, b: "" })).toStrictEqual({ a: 0 }); - const desc = Object.create(null) as { __proto__: Decoder }; + const desc = Object.create(null) as { __proto__: Codec }; desc.__proto__ = string; - const decoder2 = fieldsAuto(desc); - expect(run(decoder2, JSON.parse(`{"__proto__": "a"}`))).toStrictEqual({}); + const codec2 = fieldsAuto(desc); + expect(run(codec2, JSON.parse(`{"__proto__": "a"}`))).toStrictEqual({}); + expect( + codec2.encoder(JSON.parse(`{"__proto__": "a"}`) as Infer), + ).toStrictEqual({}); }); test("empty object", () => { - const decoder = fieldsAuto({}, { allowExtraFields: false }); - expect(run(decoder, {})).toStrictEqual({}); - expect(run(decoder, { a: 1 })).toMatchInlineSnapshot(` + const codec = fieldsAuto({}, { allowExtraFields: false }); + + expect(run(codec, {})).toStrictEqual({}); + expect(codec.encoder({})).toStrictEqual({}); + + // This should ideally have been a type error, but it is not. + // Having a codec for the empty object isn’t that useful though. + expect(codec.encoder({ a: 1 })).toStrictEqual({}); + + expect(run(codec, { a: 1 })).toMatchInlineSnapshot(` At root: Expected only these fields: (none) Found extra fields: @@ -640,8 +927,8 @@ describe("fieldsAuto", () => { describe("fieldsUnion", () => { test("basic", () => { - type Shape = Infer; - const shapeDecoder = fieldsUnion("tag", [ + type Shape = Infer; + const Shape = fieldsUnion("tag", [ { tag: tag("Circle"), radius: number, @@ -660,17 +947,49 @@ describe("fieldsUnion", () => { | { tag: "Rectangle"; width: number; height: number } > >(true); + + expectType< + TypeEqual< + InferEncoded, + | { tag: "Circle"; radius: number } + | { tag: "Rectangle"; width_px: number; height_px: number } + > + >(true); + expectType>( - shapeDecoder({ tag: "Circle", radius: 5 }), + Shape.decoder({ tag: "Circle", radius: 5 }), ); + expectType< + | { tag: "Circle"; radius: number } + | { tag: "Rectangle"; width_px: number; height_px: number } + >(Shape.encoder({ tag: "Circle", radius: 5 })); - expect(run(shapeDecoder, { tag: "Circle", radius: 5 })).toStrictEqual({ + // @ts-expect-error Object literal may only specify known properties, and 'width_px' does not exist in type '{ tag: "Rectangle"; width: number; height: number; }'. + Shape.encoder({ tag: "Rectangle", width_px: 1, height_px: 2 }); + + expect(run(Shape, { tag: "Circle", radius: 5 })).toStrictEqual({ tag: "Circle", radius: 5, }); - expect(run(shapeDecoder, { tag: "Rectangle", radius: 5 })) - .toMatchInlineSnapshot(` + expect( + run(Shape, { tag: "Rectangle", width_px: 1, height_px: 2 }), + ).toStrictEqual({ + tag: "Rectangle", + width: 1, + height: 2, + }); + + expect(Shape.encoder({ tag: "Circle", radius: 5 })).toStrictEqual({ + tag: "Circle", + radius: 5, + }); + + expect( + Shape.encoder({ tag: "Rectangle", width: 1, height: 2 }), + ).toStrictEqual({ tag: "Rectangle", width_px: 1, height_px: 2 }); + + expect(run(Shape, { tag: "Rectangle", radius: 5 })).toMatchInlineSnapshot(` At root: Expected an object with a field called: "width_px" Got: { @@ -679,8 +998,7 @@ describe("fieldsUnion", () => { } `); - expect(run(shapeDecoder, { tag: "Square", size: 5 })) - .toMatchInlineSnapshot(` + expect(run(Shape, { tag: "Square", size: 5 })).toMatchInlineSnapshot(` At root["tag"]: Expected one of these tags: "Circle", @@ -702,7 +1020,7 @@ describe("fieldsUnion", () => { expect(() => fieldsUnion("__proto__", [{ __proto__: tag("Test") }]), ).toThrowErrorMatchingInlineSnapshot( - '"fieldsUnion: commonField cannot be __proto__"', + '"fieldsUnion: decoded common field cannot be __proto__"', ); }); @@ -739,7 +1057,7 @@ describe("fieldsUnion", () => { test("encodedCommonField mismatch", () => { expect(() => - // TODO: This will be a TypeScript error in an upcoming version of tiny-decoders. + // @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", [ { tag: tag("A") }, { tag: tag("B", { renameFieldFrom: "type" }) }, @@ -749,16 +1067,36 @@ describe("fieldsUnion", () => { ); }); + 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", [ + { 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\\")."', + ); + }); + test("same encodedCommonField correctly used on every variant", () => { - const decoder = fieldsUnion("tag", [ + const codec = fieldsUnion("tag", [ { tag: tag("A", { renameFieldFrom: "type" }) }, { tag: tag("B", { renameFieldFrom: "type" }) }, ]); - expectType, { tag: "A" } | { tag: "B" }>>( + + expectType, { tag: "A" } | { tag: "B" }>>( true, ); - expect(run(decoder, { type: "A" })).toStrictEqual({ tag: "A" }); - expect(run(decoder, { type: "B" })).toStrictEqual({ tag: "B" }); + expectType< + TypeEqual, { type: "A" } | { type: "B" }> + >(true); + + expect(run(codec, { type: "A" })).toStrictEqual({ tag: "A" }); + expect(run(codec, { type: "B" })).toStrictEqual({ tag: "B" }); + + expect(codec.encoder({ tag: "A" })).toStrictEqual({ type: "A" }); + expect(codec.encoder({ tag: "B" })).toStrictEqual({ type: "B" }); }); test("same tag used twice", () => { @@ -804,50 +1142,106 @@ describe("fieldsUnion", () => { | { tag: "Err"; error: Err } | { tag: "Ok"; value: Ok }; - const resultDecoder = ( - okDecoder: Decoder, - errDecoder: Decoder, - ): Decoder> => + const Result = ( + okCodec: Codec, + errCodec: Codec, + ): Codec> => fieldsUnion("tag", [ { tag: tag("Ok"), - value: okDecoder, + value: okCodec, }, { tag: tag("Err"), - error: errDecoder, + error: errCodec, }, ]); - const decoder = resultDecoder(number, string); + const codec = Result(number, string); expectType< TypeEqual< - Infer, + Infer, { tag: "Err"; error: string } | { tag: "Ok"; value: number } > >(true); + expectType, unknown>>(true); + + // @ts-expect-error Type 'string' is not assignable to type 'number'. + codec.encoder({ tag: "Ok", value: "" }); - expect(run(decoder, { tag: "Ok", value: 0 })).toStrictEqual({ + expect(run(codec, { tag: "Ok", value: 0 })).toStrictEqual({ tag: "Ok", value: 0, }); - expect(run(decoder, { tag: "Err", error: "" })).toStrictEqual({ + expect(run(codec, { tag: "Err", error: "" })).toStrictEqual({ + tag: "Err", + error: "", + }); + + expect(codec.encoder({ tag: "Ok", value: 0 })).toStrictEqual({ + tag: "Ok", + value: 0, + }); + + expect(codec.encoder({ tag: "Err", error: "" })).toStrictEqual({ tag: "Err", error: "", }); }); + test("generic decoder with inferred encoded type", () => { + type Result = + | { tag: "Err"; error: Err } + | { tag: "Ok"; value: Ok }; + + const Result = ( + okCodec: Codec, + errCodec: Codec, + ): Codec, Result> => + fieldsUnion("tag", [ + { + tag: tag("Ok"), + value: okCodec, + }, + { + tag: tag("Err"), + error: errCodec, + }, + ]); + + const codec = Result(number, string); + + expectType< + TypeEqual< + Infer, + { tag: "Err"; error: string } | { tag: "Ok"; value: number } + > + >(true); + expectType< + TypeEqual< + InferEncoded, + { tag: "Err"; error: string } | { tag: "Ok"; value: number } + > + >(true); + + // @ts-expect-error Type 'string' is not assignable to type 'number'. + codec.encoder({ tag: "Ok", value: "" }); + + const value = { tag: "Ok", value: 0 } as const; + expect(run(codec, codec.encoder(value))).toStrictEqual(value); + }); + test("always print the expected tags in full", () => { - const decoder = fieldsUnion("tag", [ + const codec = fieldsUnion("tag", [ { tag: tag("PrettyLongTagName1"), value: string }, { tag: tag("PrettyLongTagName2"), value: string }, ]); expect( run( - decoder, + codec, { tag: "PrettyLongTagNameButWrong" }, { maxLength: 8, maxArrayChildren: 1, indent: " ".repeat(8) }, ), @@ -860,6 +1254,20 @@ describe("fieldsUnion", () => { `); }); + test("unexpectedly found no encoder for decoded variant name", () => { + const codec = fieldsUnion("tag", [ + { tag: tag("One") }, + { tag: tag("Two") }, + ]); + expect(() => + // This can only happen if you have type errors. + // @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\\""', + ); + }); + describe("allowExtraFields", () => { test("allows excess properties by default", () => { expect( @@ -874,6 +1282,7 @@ describe("fieldsUnion", () => { }, ), ).toStrictEqual({ tag: "Test", one: "a", two: true }); + expect( run( fieldsUnion( @@ -890,6 +1299,17 @@ describe("fieldsUnion", () => { }, ), ).toStrictEqual({ tag: "Test", one: "a", two: true }); + + fieldsUnion("tag", [ + { tag: tag("Test"), one: string, two: boolean }, + ]).encoder({ + tag: "Test", + one: "a", + two: true, + // @ts-expect-error Object literal may only specify known properties, and 'three' does not exist in type '{ tag: "Test"; one: string; two: boolean; }'. + three: 3, + four: {}, + }); }); test("fail on excess properties", () => { @@ -918,6 +1338,17 @@ describe("fieldsUnion", () => { "three", "four" `); + + fieldsUnion("tag", [{ tag: tag("Test"), one: string, two: boolean }], { + allowExtraFields: false, + }).encoder({ + tag: "Test", + one: "a", + two: true, + // @ts-expect-error Object literal may only specify known properties, and 'three' does not exist in type '{ tag: "Test"; one: string; two: boolean; }'. + three: 3, + four: {}, + }); }); test("large number of excess properties", () => { @@ -952,7 +1383,7 @@ describe("fieldsUnion", () => { }); test("always print the expected keys in full", () => { - const decoder = fieldsUnion( + const codec = fieldsUnion( "tag", [ { @@ -966,7 +1397,7 @@ describe("fieldsUnion", () => { expect( run( - decoder, + codec, { tag: "Test", PrettyLongTagName1: "", @@ -994,15 +1425,23 @@ describe("fieldsUnion", () => { describe("tag", () => { test("basic", () => { - const { decoder } = tag("Test"); - expectType, "Test">>(true); - expect(run(decoder, "Test")).toBe("Test"); - expect(run(decoder, "other")).toMatchInlineSnapshot(` + const { codec } = tag("Test"); + + expectType, "Test">>(true); + expectType, "Test">>(true); + + expect(run(codec, "Test")).toBe("Test"); + expect(codec.encoder("Test")).toBe("Test"); + + // @ts-expect-error Argument of type '"other"' is not assignable to parameter of type '"Test"'. + codec.encoder("other"); + + expect(run(codec, "other")).toMatchInlineSnapshot(` At root: Expected this string: "Test" Got: "other" `); - expect(run(decoder, 0)).toMatchInlineSnapshot(` + expect(run(codec, 0)).toMatchInlineSnapshot(` At root: Expected a string Got: 0 @@ -1010,10 +1449,18 @@ describe("tag", () => { }); test("renamed", () => { - const { decoder } = tag("Test", { renameTagFrom: "test" }); - expectType, "Test">>(true); - expect(run(decoder, "test")).toBe("Test"); - expect(run(decoder, "other")).toMatchInlineSnapshot(` + const { codec } = tag("Test", { renameTagFrom: "test" }); + + expectType, "Test">>(true); + expectType, "test">>(true); + + expect(run(codec, "test")).toBe("Test"); + expect(codec.encoder("Test")).toBe("test"); + + // @ts-expect-error Argument of type '"test"' is not assignable to parameter of type '"Test"'. + codec.encoder("test"); + + expect(run(codec, "other")).toMatchInlineSnapshot(` At root: Expected this string: "test" Got: "other" @@ -1022,21 +1469,30 @@ describe("tag", () => { }); describe("tuple", () => { - // @ts-expect-error Argument of type '{}' is not assignable to parameter of type 'Decoder[]'. + // @ts-expect-error Argument of type '{}' is not assignable to parameter of type 'Codec[]'. tuple({}); - // @ts-expect-error Argument of type '(value: unknown) => DecoderResult' is not assignable to parameter of type 'Decoder[]'. + // @ts-expect-error Argument of type 'Codec' is not assignable to parameter of type 'Codec[]'. tuple(number); test("0 items", () => { - type Type = Infer; - const decoder = tuple([]); + type Type = Infer; + const codec = tuple([]); expectType>(true); - expectType>(decoder([])); + expectType>>(true); + + expectType>(codec.decoder([])); + expectType(codec.encoder([])); + + expect(run(codec, [])).toStrictEqual([]); + + expect(codec.encoder([])).toStrictEqual([]); - expect(run(decoder, [])).toStrictEqual([]); + // @ts-expect-error Argument of type '[number]' is not assignable to parameter of type '[]'. + // Source has 1 element(s) but target allows only 0. + codec.encoder([1]); - expect(run(decoder, [1])).toMatchInlineSnapshot(` + expect(run(codec, [1])).toMatchInlineSnapshot(` At root: Expected 0 items Got: 1 @@ -1044,21 +1500,30 @@ describe("tuple", () => { }); test("1 item", () => { - type Type = Infer; - const decoder = tuple([number]); + type Type = Infer; + const codec = tuple([number]); expectType>(true); - expectType>(decoder([1])); + expectType>>(true); + + expectType>(codec.decoder([1])); + expectType(codec.encoder([1])); + + // @ts-expect-error Argument of type '[]' is not assignable to parameter of type '[number]'. + // Source has 0 element(s) but target requires 1. + codec.encoder([]); + + expect(run(codec, [1])).toStrictEqual([1]); - expect(run(decoder, [1])).toStrictEqual([1]); + expect(codec.encoder([1])).toStrictEqual([1]); - expect(run(decoder, [])).toMatchInlineSnapshot(` + expect(run(codec, [])).toMatchInlineSnapshot(` At root: Expected 1 items Got: 0 `); - expect(run(decoder, [1, 2])).toMatchInlineSnapshot(` + expect(run(codec, [1, 2])).toMatchInlineSnapshot(` At root: Expected 1 items Got: 2 @@ -1066,27 +1531,36 @@ describe("tuple", () => { }); test("2 items", () => { - type Type = Infer; - const decoder = tuple([number, string]); + type Type = Infer; + const codec = tuple([number, string]); expectType>(true); - expectType>(decoder([1, "a"])); + expectType>>(true); - expect(run(decoder, [1, "a"])).toStrictEqual([1, "a"]); + expectType>(codec.decoder([1, "a"])); + expectType(codec.encoder([1, "a"])); - expect(run(decoder, [1])).toMatchInlineSnapshot(` + // @ts-expect-error Argument of type '[number]' is not assignable to parameter of type '[number, string]'. + // Source has 1 element(s) but target requires 2. + codec.encoder([1]); + + expect(run(codec, [1, "a"])).toStrictEqual([1, "a"]); + + expect(codec.encoder([1, "a"])).toStrictEqual([1, "a"]); + + expect(run(codec, [1])).toMatchInlineSnapshot(` At root: Expected 2 items Got: 1 `); - expect(run(decoder, ["a", 1])).toMatchInlineSnapshot(` + expect(run(codec, ["a", 1])).toMatchInlineSnapshot(` At root[0]: Expected a number Got: "a" `); - expect(run(decoder, [1, "a", 2])).toMatchInlineSnapshot(` + expect(run(codec, [1, "a", 2])).toMatchInlineSnapshot(` At root: Expected 2 items Got: 3 @@ -1094,21 +1568,30 @@ describe("tuple", () => { }); test("3 items", () => { - type Type = Infer; - const decoder = tuple([number, string, boolean]); + type Type = Infer; + const codec = tuple([number, string, boolean]); expectType>(true); - expectType>(decoder([1, "a", true])); + expectType>>(true); + + expectType>(codec.decoder([1, "a", true])); + expectType(codec.encoder([1, "a", true])); + + // @ts-expect-error Argument of type '[number, string]' is not assignable to parameter of type '[number, string, boolean]'. + // Source has 2 element(s) but target requires 3. + codec.encoder([1, "a"]); - expect(run(decoder, [1, "a", true])).toStrictEqual([1, "a", true]); + expect(run(codec, [1, "a", true])).toStrictEqual([1, "a", true]); - expect(run(decoder, [1, "a"])).toMatchInlineSnapshot(` + expect(codec.encoder([1, "a", true])).toStrictEqual([1, "a", true]); + + expect(run(codec, [1, "a"])).toMatchInlineSnapshot(` At root: Expected 3 items Got: 2 `); - expect(run(decoder, [1, "a", true, 2])).toMatchInlineSnapshot(` + expect(run(codec, [1, "a", true, 2])).toMatchInlineSnapshot(` At root: Expected 3 items Got: 4 @@ -1116,15 +1599,24 @@ describe("tuple", () => { }); test("4 items", () => { - type Type = Infer; - const decoder = tuple([number, string, boolean, number]); + type Type = Infer; + const codec = tuple([number, string, boolean, number]); expectType>(true); - expectType>(decoder([1, "a", true, 2])); + expectType>>(true); + + expectType>(codec.decoder([1, "a", true, 2])); + expectType(codec.encoder([1, "a", true, 2])); - expect(run(decoder, [1, "a", true, 2])).toStrictEqual([1, "a", true, 2]); + // @ts-expect-error Argument of type '[number, string, true]' is not assignable to parameter of type '[number, string, boolean, number]'. + // Source has 3 element(s) but target requires 4. + codec.encoder([1, "a", true]); - expect(run(decoder, [1, "a", true])).toMatchInlineSnapshot(` + expect(run(codec, [1, "a", true, 2])).toStrictEqual([1, "a", true, 2]); + + expect(codec.encoder([1, "a", true, 2])).toStrictEqual([1, "a", true, 2]); + + expect(run(codec, [1, "a", true])).toMatchInlineSnapshot(` At root: Expected 4 items Got: 3 @@ -1132,7 +1624,7 @@ describe("tuple", () => { expect( // eslint-disable-next-line no-sparse-arrays - run(decoder, [1, "a", true, 2, "too", , , "many"]), + run(codec, [1, "a", true, 2, "too", , , "many"]), ).toMatchInlineSnapshot(` At root: Expected 4 items @@ -1140,6 +1632,43 @@ describe("tuple", () => { `); }); + test("different decoded and encoded types", () => { + type Type = Infer; + const codec = tuple([ + map(boolean, { decoder: Number, encoder: Boolean }), + fieldsAuto({ decoded: field(string, { renameFrom: "encoded" }) }), + ]); + + expectType>(true); + expectType< + TypeEqual, [boolean, { encoded: string }]> + >(true); + + expectType>(codec.decoder([true, { encoded: "" }])); + expectType<[boolean, { encoded: string }]>( + codec.encoder([1, { decoded: "" }]), + ); + + // @ts-expect-error Type 'boolean' is not assignable to type 'number'. + codec.encoder([true, { decoded: "" }]); + + expect(run(codec, [true, { encoded: "" }])).toStrictEqual([ + 1, + { decoded: "" }, + ]); + + expect(codec.encoder([1, { decoded: "" }])).toStrictEqual([ + true, + { encoded: "" }, + ]); + + expect(run(codec, [1, { encoded: "" }])).toMatchInlineSnapshot(` + At root[0]: + Expected a boolean + Got: 1 + `); + }); + test("allow only arrays", () => { expect(run(tuple([number]), { length: 0 })).toMatchInlineSnapshot(` At root: @@ -1148,18 +1677,25 @@ describe("tuple", () => { "length": 0 } `); + + // @ts-expect-error Type '0' is not assignable to type '1'. + tuple([number]).encoder({ length: 0 }); + expect(run(tuple([number]), new Int32Array(2))).toMatchInlineSnapshot(` At root: Expected an array Got: Int32Array `); + + // @ts-expect-error Argument of type 'Int32Array' is not assignable to parameter of type '[number]'. + tuple([number]).encoder(new Int32Array(2)); }); }); describe("multi", () => { test("basic", () => { - type Id = Infer; - const idDecoder = multi(["string", "number"]); + type Id = Infer; + const Id = multi(["string", "number"]); expectType< TypeEqual< @@ -1167,16 +1703,33 @@ describe("multi", () => { { type: "number"; value: number } | { type: "string"; value: string } > >(true); - expectType>(idDecoder("123")); + expectType, number | string>>(true); + + expectType>(Id.decoder("123")); + expectType(Id.encoder({ type: "string", value: "123" })); - expect(run(idDecoder, "123")).toStrictEqual({ + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '{ type: "string"; value: string; } | { type: "number"; value: number; }'. + Id.encoder("123"); + // @ts-expect-error Type '"boolean"' is not assignable to type '"string" | "number"'. + Id.encoder({ type: "boolean", value: true }); + + expect(run(Id, "123")).toStrictEqual({ type: "string", value: "123", }); - expect(run(idDecoder, 123)).toStrictEqual({ type: "number", value: 123 }); + expect( + Id.encoder({ + type: "string", + value: "123", + }), + ).toBe("123"); - expect(run(idDecoder, true)).toMatchInlineSnapshot(` + expect(run(Id, 123)).toStrictEqual({ type: "number", value: 123 }); + + expect(Id.encoder({ type: "number", value: 123 })).toBe(123); + + expect(run(Id, true)).toMatchInlineSnapshot(` At root: Expected one of these types: string, number Got: true @@ -1184,26 +1737,47 @@ describe("multi", () => { }); test("basic – mapped", () => { - type Id = Infer; - const idDecoder = map(multi(["string", "number"]), (value) => { - switch (value.type) { - case "string": - return { tag: "Id" as const, id: value.value }; - case "number": - return { tag: "LegacyId" as const, id: value.value }; - } + type Id = Infer; + const Id = map(multi(["string", "number"]), { + decoder: (value) => { + switch (value.type) { + case "string": + return { tag: "Id" as const, id: value.value }; + case "number": + return { tag: "LegacyId" as const, id: value.value }; + } + }, + encoder: (id) => { + switch (id.tag) { + case "Id": + return { type: "string", value: id.id }; + case "LegacyId": + return { type: "number", value: id.id }; + } + }, }); expectType< TypeEqual >(true); - expectType>(idDecoder("123")); - expect(run(idDecoder, "123")).toStrictEqual({ tag: "Id", id: "123" }); + expectType, number | string>>(true); - expect(run(idDecoder, 123)).toStrictEqual({ tag: "LegacyId", id: 123 }); + expectType>(Id.decoder("123")); + expectType(Id.encoder({ tag: "Id", id: "123" })); - expect(run(idDecoder, true)).toMatchInlineSnapshot(` + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type '{ tag: "Id"; id: string; } | { tag: "LegacyId"; id: number; }'. + expect(() => Id.encoder("123")).toThrow(); + + expect(run(Id, "123")).toStrictEqual({ tag: "Id", id: "123" }); + + expect(Id.encoder({ tag: "Id", id: "123" })).toBe("123"); + + expect(run(Id, 123)).toStrictEqual({ tag: "LegacyId", id: 123 }); + + expect(Id.encoder({ tag: "LegacyId", id: 123 })).toBe(123); + + expect(run(Id, true)).toMatchInlineSnapshot(` At root: Expected one of these types: string, number Got: true @@ -1211,23 +1785,34 @@ describe("multi", () => { }); test("basic – variation", () => { - type Id = Infer; - const idDecoder = map(multi(["string", "number"]), (value) => { - switch (value.type) { - case "string": - return value.value; - case "number": - return value.value.toString(); - } + type Id = Infer; + const Id = map(multi(["string", "number"]), { + decoder: (value) => { + switch (value.type) { + case "string": + return value.value; + case "number": + return value.value.toString(); + } + }, + encoder: (value) => ({ type: "string", value }), }); expectType>(true); - expectType>(idDecoder("123")); + expectType, number | string>>(true); + + expectType>(Id.decoder("123")); + expectType(Id.encoder("123")); - expect(run(idDecoder, "123")).toBe("123"); - expect(run(idDecoder, 123)).toBe("123"); + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'string'. + Id.encoder(123); - expect(run(idDecoder, true)).toMatchInlineSnapshot(` + expect(run(Id, "123")).toBe("123"); + expect(run(Id, 123)).toBe("123"); + + expect(Id.encoder("123")).toBe("123"); + + expect(run(Id, true)).toMatchInlineSnapshot(` At root: Expected one of these types: string, number Got: true @@ -1237,17 +1822,23 @@ describe("multi", () => { test("empty array", () => { // @ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [MultiTypeName, ...MultiTypeName[]]'. // Source has 0 element(s) but target requires 1. - const decoder = multi([]); + const codec = multi([]); - expect(run(decoder, undefined)).toMatchInlineSnapshot(` + expect(run(codec, undefined)).toMatchInlineSnapshot(` At root: Expected one of these types: never Got: undefined `); + + // Would have been cool if this was a TypeScript error due to `never`, + // but it’s good enough with having an error at the definition. + expect( + codec.encoder({ type: "undefined", value: undefined }), + ).toBeUndefined(); }); test("all types", () => { - const decoder = multi([ + const codec = multi([ "undefined", "null", "boolean", @@ -1271,44 +1862,44 @@ describe("multi", () => { ]; for (const value of values) { - expect(run(decoder, value)).toMatchObject({ value }); + expect(run(codec, value)).toMatchObject({ value }); } }); test("coverage", () => { - const decoder = multi(["undefined"]); + const codec = multi(["undefined"]); - expect(run(decoder, null)).toMatchInlineSnapshot(` + expect(run(codec, null)).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: null `); - expect(run(decoder, true)).toMatchInlineSnapshot(` + expect(run(codec, true)).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: true `); - expect(run(decoder, 0)).toMatchInlineSnapshot(` + expect(run(codec, 0)).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: 0 `); - expect(run(decoder, "")).toMatchInlineSnapshot(` + expect(run(codec, "")).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: "" `); - expect(run(decoder, [])).toMatchInlineSnapshot(` + expect(run(codec, [])).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: [] `); - expect(run(decoder, {})).toMatchInlineSnapshot(` + expect(run(codec, {})).toMatchInlineSnapshot(` At root: Expected one of these types: undefined Got: {} @@ -1323,29 +1914,37 @@ describe("recursive", () => { b: Array; }; - const decoder: Decoder = fieldsAuto({ + const codec: Codec = fieldsAuto({ a: field( - recursive(() => decoder), + recursive(() => codec), { optional: true }, ), - b: array(recursive(() => decoder)), + b: array(recursive(() => codec)), }); const input = { a: { b: [] }, b: [{ a: { b: [] }, b: [] }] }; - expect(run(decoder, input)).toStrictEqual(input); + expect(run(codec, input)).toStrictEqual(input); + expect(codec.encoder(input)).toStrictEqual(input); }); }); describe("undefinedOr", () => { test("undefined or string", () => { - const decoder = undefinedOr(string); + const codec = undefinedOr(string); + + expectType, string | undefined>>(true); + expectType, string | undefined>>(true); + + // @ts-expect-error Argument of type 'null' is not assignable to parameter of type 'string | undefined'. + codec.encoder(null); - expectType, string | undefined>>(true); + expect(run(codec, undefined)).toBeUndefined(); + expect(run(codec, "a")).toBe("a"); - expect(run(decoder, undefined)).toBeUndefined(); - expect(run(decoder, "a")).toBe("a"); + expect(codec.encoder(undefined)).toBeUndefined(); + expect(codec.encoder("a")).toBe("a"); - expect(run(decoder, null)).toMatchInlineSnapshot(` + expect(run(codec, null)).toMatchInlineSnapshot(` At root: Expected a string Got: null @@ -1354,17 +1953,26 @@ describe("undefinedOr", () => { }); test("with default", () => { - const decoder = map(undefinedOr(string), (value) => value ?? "def"); + const codec = map(undefinedOr(string), { + decoder: (value) => value ?? "def", + encoder: (value) => value, + }); + + expectType, string>>(true); + expectType, string | undefined>>(true); + + expect(run(codec, undefined)).toBe("def"); + expect(run(codec, "a")).toBe("a"); - expectType, string>>(true); + // @ts-expect-error Argument of type 'undefined' is not assignable to parameter of type 'string'. + codec.encoder(undefined); - expect(run(decoder, undefined)).toBe("def"); - expect(run(decoder, "a")).toBe("a"); + expect(codec.encoder("a")).toBe("a"); }); test("using with fieldsAuto does NOT result in an optional field", () => { - type Person = Infer; - const personDecoder = fieldsAuto({ + type Person = Infer; + const Person = fieldsAuto({ name: string, age: undefinedOr(number), }); @@ -1372,8 +1980,9 @@ describe("undefinedOr", () => { expectType>( true, ); + expectType>>(true); - expect(run(personDecoder, { name: "John" })).toMatchInlineSnapshot(` + expect(run(Person, { name: "John" })).toMatchInlineSnapshot(` At root: Expected an object with a field called: "age" Got: { @@ -1381,18 +1990,27 @@ describe("undefinedOr", () => { } `); - expect(run(personDecoder, { name: "John", age: undefined })).toStrictEqual({ + expect(run(Person, { name: "John", age: undefined })).toStrictEqual({ name: "John", age: undefined, }); - expect(run(personDecoder, { name: "John", age: 45 })).toStrictEqual({ + expect(Person.encoder({ name: "John", age: undefined })).toStrictEqual({ + name: "John", + age: undefined, + }); + + expect(run(Person, { name: "John", age: 45 })).toStrictEqual({ name: "John", age: 45, }); - expect(run(personDecoder, { name: "John", age: "old" })) - .toMatchInlineSnapshot(` + expect(Person.encoder({ name: "John", age: 45 })).toStrictEqual({ + name: "John", + age: 45, + }); + + expect(run(Person, { name: "John", age: "old" })).toMatchInlineSnapshot(` At root["age"]: Expected a number Got: "old" @@ -1407,9 +2025,9 @@ describe("undefinedOr", () => { void person2; }); - test("undefined or custom decoder", () => { - function decoder(value: unknown): DecoderResult { - return { + test("undefined or custom codec", () => { + const codec: Codec = { + decoder: (value) => ({ tag: "DecoderError", error: { tag: "custom", @@ -1417,46 +2035,63 @@ describe("undefinedOr", () => { got: value, path: [], }, - }; - } - expect(run(undefinedOr(decoder), 1)).toMatchInlineSnapshot(` + }), + encoder: () => { + throw new Error("never"); + }, + }; + + expect(run(undefinedOr(codec), 1)).toMatchInlineSnapshot(` At root: fail Got: 1 Or expected: undefined `); + + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'never'. + expect(() => codec.encoder(1)).toThrowErrorMatchingInlineSnapshot( + '"never"', + ); }); test("undefinedOr higher up the chain makes no difference", () => { - const decoder = fieldsAuto({ + const codec = fieldsAuto({ test: undefinedOr(fieldsAuto({ inner: string })), }); - expect(run(decoder, { test: 1 })).toMatchInlineSnapshot(` + expect(run(codec, { test: 1 })).toMatchInlineSnapshot(` At root["test"]: Expected an object Got: 1 Or expected: undefined `); - expect(run(decoder, { test: { inner: 1 } })).toMatchInlineSnapshot(` + expect(run(codec, { test: { inner: 1 } })).toMatchInlineSnapshot(` At root["test"]["inner"]: Expected a string Got: 1 + Or expected: undefined `); }); }); describe("nullable", () => { test("nullable string", () => { - const decoder = nullable(string); + const codec = nullable(string); + + expectType, string | null>>(true); + expectType, string | null>>(true); - expectType, string | null>>(true); + // @ts-expect-error Argument of type 'undefined' is not assignable to parameter of type 'string | null'. + codec.encoder(undefined); - expect(run(decoder, null)).toBeNull(); - expect(run(decoder, "a")).toBe("a"); + expect(run(codec, null)).toBeNull(); + expect(run(codec, "a")).toBe("a"); - expect(run(decoder, undefined)).toMatchInlineSnapshot(` + expect(codec.encoder(null)).toBeNull(); + expect(codec.encoder("a")).toBe("a"); + + expect(run(codec, undefined)).toMatchInlineSnapshot(` At root: Expected a string Got: undefined @@ -1465,33 +2100,53 @@ describe("nullable", () => { }); test("with default", () => { - const decoder = map(nullable(string), (value) => value ?? "def"); + const codec = map(nullable(string), { + decoder: (value) => value ?? "def", + encoder: (value) => value, + }); + + expectType, string>>(true); + expectType, string | null>>(true); - expectType, string>>(true); + // @ts-expect-error Argument of type 'null' is not assignable to parameter of type 'string'. + codec.encoder(null); - expect(run(decoder, null)).toBe("def"); - expect(run(decoder, "a")).toBe("a"); + expect(run(codec, null)).toBe("def"); + expect(run(codec, "a")).toBe("a"); + + expect(codec.encoder("a")).toBe("a"); }); test("with undefined instead of null", () => { - const decoder = map(nullable(string), (value) => value ?? undefined); + const codec = map(nullable(string), { + decoder: (value) => value ?? undefined, + encoder: (value) => value ?? null, + }); + + expectType, string | undefined>>(true); + expectType, string | null>>(true); - expectType, string | undefined>>(true); + // @ts-expect-error Argument of type 'null' is not assignable to parameter of type 'string | undefined'. + codec.encoder(null); - expect(run(decoder, null)).toBeUndefined(); - expect(run(decoder, "a")).toBe("a"); + expect(run(codec, null)).toBeUndefined(); + expect(run(codec, "a")).toBe("a"); + + expect(codec.encoder(undefined)).toBeNull(); + expect(codec.encoder("a")).toBe("a"); }); test("nullable field", () => { - type Person = Infer; - const personDecoder = fieldsAuto({ + type Person = Infer; + const Person = fieldsAuto({ name: string, age: nullable(number), }); expectType>(true); + expectType>>(true); - expect(run(personDecoder, { name: "John" })).toMatchInlineSnapshot(` + expect(run(Person, { name: "John" })).toMatchInlineSnapshot(` At root: Expected an object with a field called: "age" Got: { @@ -1499,7 +2154,7 @@ describe("nullable", () => { } `); - expect(run(personDecoder, { name: "John", age: undefined })) + expect(run(Person, { name: "John", age: undefined })) .toMatchInlineSnapshot(` At root["age"]: Expected a number @@ -1507,18 +2162,27 @@ describe("nullable", () => { Or expected: null `); - expect(run(personDecoder, { name: "John", age: null })).toStrictEqual({ + expect(run(Person, { name: "John", age: null })).toStrictEqual({ + name: "John", + age: null, + }); + + expect(Person.encoder({ name: "John", age: null })).toStrictEqual({ name: "John", age: null, }); - expect(run(personDecoder, { name: "John", age: 45 })).toStrictEqual({ + expect(run(Person, { name: "John", age: 45 })).toStrictEqual({ name: "John", age: 45, }); - expect(run(personDecoder, { name: "John", age: "old" })) - .toMatchInlineSnapshot(` + expect(Person.encoder({ name: "John", age: 45 })).toStrictEqual({ + name: "John", + age: 45, + }); + + expect(run(Person, { name: "John", age: "old" })).toMatchInlineSnapshot(` At root["age"]: Expected a number Got: "old" @@ -1530,9 +2194,9 @@ describe("nullable", () => { void person; }); - test("nullable custom decoder", () => { - function decoder(value: unknown): DecoderResult { - return { + test("nullable custom codec", () => { + const codec: Codec = { + decoder: (value) => ({ tag: "DecoderError", error: { tag: "custom", @@ -1540,40 +2204,54 @@ describe("nullable", () => { got: value, path: [], }, - }; - } + }), + encoder: () => { + throw new Error("never"); + }, + }; - expect(run(nullable(decoder), 1)).toMatchInlineSnapshot(` + expect(run(nullable(codec), 1)).toMatchInlineSnapshot(` At root: fail Got: 1 Or expected: null `); + + // @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'never'. + expect(() => codec.encoder(1)).toThrowErrorMatchingInlineSnapshot( + '"never"', + ); }); test("nullable higher up the chain makes no difference", () => { - const decoder = fieldsAuto({ + const codec = fieldsAuto({ test: nullable(fieldsAuto({ inner: string })), }); - expect(run(decoder, { test: 1 })).toMatchInlineSnapshot(` + expect(run(codec, { test: 1 })).toMatchInlineSnapshot(` At root["test"]: Expected an object Got: 1 Or expected: null `); - expect(run(decoder, { test: { inner: 1 } })).toMatchInlineSnapshot(` + expect(run(codec, { test: { inner: 1 } })).toMatchInlineSnapshot(` At root["test"]["inner"]: Expected a string Got: 1 + Or expected: null `); }); test("undefinedOr and nullable", () => { - const decoder = undefinedOr(nullable(nullable(undefinedOr(string)))); + const codec = undefinedOr(nullable(nullable(undefinedOr(string)))); + + expectType, string | null | undefined>>(true); + expectType< + TypeEqual, string | null | undefined> + >(true); - expect(run(decoder, 1)).toMatchInlineSnapshot(` + expect(run(codec, 1)).toMatchInlineSnapshot(` At root: Expected a string Got: 1 @@ -1583,72 +2261,68 @@ describe("nullable", () => { }); test("map", () => { - expect(run(map(number, Math.round), 4.9)).toBe(5); - - expect( - run( - map(array(number), (arr) => new Set(arr)), - [1, 2, 1], - ), - ).toStrictEqual(new Set([1, 2])); - - expect(map(number, string)(0)).toMatchInlineSnapshot(` - { - "tag": "Valid", - "value": { - "error": { - "got": 0, - "path": [], - "tag": "string", - }, - "tag": "DecoderError", - }, - } - `); - - expect(run(map(number, string), "string")).toMatchInlineSnapshot(` + const roundNumberCodec = map(number, { + decoder: Math.round, + encoder: (value) => value, + }); + expectType, number>>(true); + expectType, number>>(true); + expect(run(roundNumberCodec, 4.9)).toBe(5); + expect(roundNumberCodec.encoder(4.9)).toBe(4.9); + expect(run(roundNumberCodec, "4.9")).toMatchInlineSnapshot(` At root: Expected a number - Got: "string" + Got: "4.9" `); + + const setCodec = map(array(number), { + decoder: (arr) => new Set(arr), + encoder: Array.from, + }); + expectType, Set>>(true); + expectType, Array>>(true); + expect(run(setCodec, [1, 2, 1])).toStrictEqual(new Set([1, 2])); + expect(setCodec.encoder(new Set([1, 2]))).toStrictEqual([1, 2]); }); test("flatMap", () => { - expect( - run( - flatMap(number, (n) => ({ tag: "Valid", value: Math.round(n) })), - 4.9, - ), - ).toBe(5); - - expect( - run( - flatMap(number, (n) => ({ - tag: "DecoderError", - error: { - tag: "custom", - message: "The error message", - got: n, - path: ["some", "path", 0], - }, - })), - 4.9, - ), - ).toMatchInlineSnapshot(` + const roundNumberCodec = flatMap(number, { + decoder: (n) => ({ + tag: "Valid", + value: Math.round(n), + }), + encoder: (value) => value, + }); + expectType, number>>(true); + expectType, number>>(true); + expect(run(roundNumberCodec, 4.9)).toBe(5); + expect(roundNumberCodec.encoder(4.9)).toBe(4.9); + expect(run(roundNumberCodec, "4.9")).toMatchInlineSnapshot(` + At root: + Expected a number + Got: "4.9" + `); + + const failCodec = flatMap(number, { + decoder: (n) => ({ + tag: "DecoderError", + error: { + tag: "custom", + message: "The error message", + got: n, + path: ["some", "path", 0], + }, + }), + encoder: () => 1, + }); + expectType, unknown>>(true); + expectType, number>>(true); + + expect(run(failCodec, 4.9)).toMatchInlineSnapshot(` At root["some"]["path"][0]: The error message Got: 4.9 `); - expect(run(flatMap(number, string), 0)).toMatchInlineSnapshot(` - At root: - Expected a string - Got: 0 - `); - - expect(run(flatMap(number, string), "string")).toMatchInlineSnapshot(` - At root: - Expected a number - Got: "string" - `); + expect(failCodec.encoder(4.9)).toBe(1); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 981036b..ebd8fc9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,13 +1,13 @@ import { expect } from "vitest"; -import { Decoder, format, ReprOptions } from ".."; +import { Codec, format, ReprOptions } from ".."; -export function run( - decoder: Decoder, +export function run( + codec: Codec, value: unknown, options?: ReprOptions, -): T | string { - const decoderResult = decoder(value); +): Decoded | string { + const decoderResult = codec.decoder(value); switch (decoderResult.tag) { case "DecoderError": return format(decoderResult.error, options);