From 530fa9e36b8532589b948fc4faa37593f36b7f42 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Sat, 22 Jun 2024 16:13:21 +0200 Subject: [PATCH] Fix error message display for composite errors when `overwrite = false` (#3046) --- .changeset/breezy-houses-cover.md | 25 + packages/schema/README.md | 269 +++++++-- packages/schema/src/AST.ts | 4 +- packages/schema/src/Schema.ts | 99 ++-- packages/schema/src/TreeFormatter.ts | 70 +-- packages/schema/test/Formatter.test.ts | 525 ++++++++++-------- .../test/Schema/Either/EitherFromSelf.test.ts | 30 +- .../test/Schema/Exit/ExitFromSelf.test.ts | 35 ++ packages/schema/test/TestUtils.ts | 5 + 9 files changed, 671 insertions(+), 391 deletions(-) create mode 100644 .changeset/breezy-houses-cover.md diff --git a/.changeset/breezy-houses-cover.md b/.changeset/breezy-houses-cover.md new file mode 100644 index 0000000000..21704ec76e --- /dev/null +++ b/.changeset/breezy-houses-cover.md @@ -0,0 +1,25 @@ +--- +"@effect/schema": patch +--- + +Fix error message display for composite errors when `overwrite = false` + +This commit resolves an issue where the custom message for a struct (or tuple or union) was displayed regardless of whether the validation error was related to the entire struct or just a specific part of it. Previously, users would see the custom error message even when the error only concerned a particular field within the struct and the flag `overwrite` was not set to `true`. + +```ts +import { Schema, TreeFormatter } from "@effect/schema" +import { Either } from "effect" + +const schema = Schema.Struct({ + a: Schema.String +}).annotations({ message: () => "custom message" }) + +const res = Schema.decodeUnknownEither(schema)({ a: null }) +if (Either.isLeft(res)) { + console.log(TreeFormatter.formatErrorSync(res.left)) + // before: custom message + // now: { readonly a: string } + // └─ ["a"] + // └─ Expected string, actual null +} +``` diff --git a/packages/schema/README.md b/packages/schema/README.md index 2e982dcf79..44deb7f7e2 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -5240,7 +5240,9 @@ const Category = Schema.Struct({ ### Default Error Messages -When a parsing, decoding, or encoding process encounters a failure, a default error message is automatically generated for you. Let's explore some examples: +By default, when a parsing error occurs, the system automatically generates an informative message based on the schema's structure and the nature of the error. For example, if a required property is missing or a data type does not match, the error message will clearly state the expectation versus the actual input. + +**Type Mismatch Example** ```ts import { Schema } from "@effect/schema" @@ -5255,6 +5257,17 @@ Schema.decodeUnknownSync(schema)(null) throws: Error: Expected { readonly name: string; readonly age: number }, actual null */ +``` + +**Missing Properties Example** + +```ts +import { Schema } from "@effect/schema" + +const schema = Schema.Struct({ + name: Schema.String, + age: Schema.Number +}) Schema.decodeUnknownSync(schema)({}, { errors: "all" }) /* @@ -5267,25 +5280,52 @@ Error: { readonly name: string; readonly age: number } */ ``` -#### Identifiers in Error Messages - -When you include an identifier annotation, it will be incorporated into the default error message, followed by a description if provided: +**Incorrect Property Type Example** ```ts import { Schema } from "@effect/schema" const schema = Schema.Struct({ - name: Schema.String.annotations({ identifier: "Name" }), - age: Schema.Number.annotations({ identifier: "Age" }) + name: Schema.String, + age: Schema.Number +}) + +Schema.decodeUnknownSync(schema)({ name: null, age: "age" }, { errors: "all" }) +/* +throws: +ParseError: { readonly name: string; readonly age: number } +├─ ["name"] +│ └─ Expected string, actual null +└─ ["age"] + └─ Expected number, actual "age" +*/ +``` + +#### Enhancing Clarity in Error Messages with Identifiers + +In scenarios where a schema has multiple fields or nested structures, the default error messages can become overly complex and verbose. To address this, you can enhance the clarity and brevity of these messages by utilizing annotations such as `identifier`, `title`, and `description`. + +Incorporating an `identifier` annotation into your schema allows you to customize the error messages, making them more succinct and directly relevant to the specific part of the schema that triggered the error. Here's how you can apply this in practice: + +```ts +import { Schema } from "@effect/schema" + +const Name = Schema.String.annotations({ identifier: "Name" }) + +const Age = Schema.Number.annotations({ identifier: "Age" }) + +const Person = Schema.Struct({ + name: Name, + age: Age }).annotations({ identifier: "Person" }) -Schema.decodeUnknownSync(schema)(null) +Schema.decodeUnknownSync(Person)(null) /* throws: Error: Expected Person, actual null */ -Schema.decodeUnknownSync(schema)({}, { errors: "all" }) +Schema.decodeUnknownSync(Person)({}, { errors: "all" }) /* throws: Error: Person @@ -5295,7 +5335,7 @@ Error: Person └─ is missing */ -Schema.decodeUnknownSync(schema)({ name: null, age: null }, { errors: "all" }) +Schema.decodeUnknownSync(Person)({ name: null, age: null }, { errors: "all" }) /* throws: Error: Person @@ -5313,13 +5353,17 @@ When a refinement fails, the default error message indicates whether the failure ```ts import { Schema } from "@effect/schema" -const schema = Schema.Struct({ - name: Schema.NonEmpty.annotations({ identifier: "Name" }), // refinement - age: Schema.Positive.pipe(Schema.int({ identifier: "Age" })) // refinement +const Name = Schema.NonEmpty.annotations({ identifier: "Name" }) // refinement + +const Age = Schema.Positive.pipe(Schema.int({ identifier: "Age" })) // refinement + +const Person = Schema.Struct({ + name: Name, + age: Age }).annotations({ identifier: "Person" }) -// "from" failure -Schema.decodeUnknownSync(schema)({ name: null, age: 18 }) +// From side failure +Schema.decodeUnknownSync(Person)({ name: null, age: 18 }) /* throws: Error: Person @@ -5329,8 +5373,8 @@ Error: Person └─ Expected a string, actual null */ -// predicate failure -Schema.decodeUnknownSync(schema)({ name: "", age: 18 }) +// Predicate refinement failure +Schema.decodeUnknownSync(Person)({ name: "", age: 18 }) /* throws: Error: Person @@ -5341,11 +5385,64 @@ Error: Person */ ``` -In the first example, the error message indicates a "from" side refinement failure in the "Name" property, specifying that a string was expected but received null. In the second example, a predicate refinement failure is reported, indicating that a non-empty string was expected for "Name," but an empty string was provided. +In the first example, the error message indicates a "from side" refinement failure in the `name` property, specifying that a string was expected but received `null`. In the second example, a "predicate" refinement failure is reported, indicating that a non-empty string was expected for `name` but an empty string was provided. + +#### Transformations + +Transformations between different types or formats can occasionally result in errors. The system provides a structured error message to specify where the error occurred: + +- **Encoded Side Failure:** Errors on this side typically indicate that the input to the transformation does not match the expected initial type or format. For example, receiving a `null` when a `string` is expected. +- **Transformation Process Failure:** This type of error arises when the transformation logic itself fails, such as when the input does not meet the criteria specified within the transformation functions. +- **Type Side Failure:** Occurs when the output of a transformation does not meet the schema requirements on the decoded side. This can happen if the transformed value fails subsequent validations or conditions. + +```ts +import { ParseResult, Schema } from "@effect/schema" + +const schema = Schema.transformOrFail( + Schema.String, + Schema.String.pipe(Schema.minLength(2)), + { + decode: (s, _, ast) => + s.length > 0 + ? ParseResult.succeed(s) + : ParseResult.fail(new ParseResult.Type(ast, s)), + encode: ParseResult.succeed + } +) + +// Encoded side failure +Schema.decodeUnknownSync(schema)(null) +/* +throws: +ParseError: (string <-> string) +└─ Encoded side transformation failure + └─ Expected string, actual null +*/ + +// transformation failure +Schema.decodeUnknownSync(schema)("") +/* +throws: +ParseError: (string <-> string) +└─ Transformation process failure + └─ Expected (string <-> string), actual "" +*/ + +// Type side failure +Schema.decodeUnknownSync(schema)("a") +/* +throws: +ParseError: (string <-> a string at least 2 character(s) long) +└─ Type side transformation failure + └─ a string at least 2 character(s) long + └─ Predicate refinement failure + └─ Expected a string at least 2 character(s) long, actual "a" +*/ +``` ### Custom Error Messages -Custom messages can be set using the `message` annotation: +You have the capability to define custom error messages specifically tailored for different parts of your schema using the `message` annotation. This allows developers to provide more context-specific feedback which can improve the debugging and validation processes. ```ts type MessageAnnotation = (issue: ParseIssue) => @@ -5357,6 +5454,10 @@ type MessageAnnotation = (issue: ParseIssue) => } ``` +- **String**: A straightforward message that describes the error. +- **Effect**: Allows for dynamic error messages that might depend on **synchronous** processes or **optional** dependencies. +- **Object with `message` and `override`**: Allows you to define a specific error message along with a boolean flag (`override`). This flag determines if the custom message should supersede any default or nested custom messages, providing precise control over the error output displayed to users. + Here's a simple example of how to set a custom message for the built-in `String` schema: ```ts @@ -5365,6 +5466,12 @@ import { Schema } from "@effect/schema" const MyString = Schema.String.annotations({ message: () => "my custom message" }) + +Schema.decodeUnknownSync(MyString)(null) +/* +throws: +ParseError: my custom message +*/ ``` #### General Guidelines for Messages @@ -5388,9 +5495,13 @@ const MyString = Schema.String.annotations({ message: () => "my custom message" }) -const decode = Schema.decodeUnknownEither(MyString) +const decode = Schema.decodeUnknownSync(MyString) -console.log(decode(null)) // "my custom message" +try { + decode(null) +} catch (e: any) { + console.log(e.message) // "my custom message" +} ``` #### Refinements @@ -5408,11 +5519,40 @@ const MyString = Schema.String.pipe( message: () => "my custom message" }) -const decode = Schema.decodeUnknownEither(MyString) +const decode = Schema.decodeUnknownSync(MyString) -console.log(decode(null)) // "Expected a string, actual null" -console.log(decode("")) // `Expected a string at least 1 character(s) long, actual ""` -console.log(decode("abc")) // "my custom message" +try { + decode(null) +} catch (e: any) { + console.log(e.message) + /* + a string at most 2 character(s) long + └─ From side refinement failure + └─ a string at least 1 character(s) long + └─ From side refinement failure + └─ Expected string, actual null + */ +} + +try { + decode("") +} catch (e: any) { + console.log(e.message) + /* + a string at most 2 character(s) long + └─ From side refinement failure + └─ a string at least 1 character(s) long + └─ Predicate refinement failure + └─ Expected a string at least 1 character(s) long, actual "" + */ +} + +try { + decode("abc") +} catch (e: any) { + console.log(e.message) + // "my custom message" +} ``` When setting multiple override messages, the one corresponding to the **first** failed predicate is used, starting from the innermost refinement to the outermost: @@ -5430,11 +5570,25 @@ const MyString = Schema.String Schema.maxLength(2, { message: () => "maxLength custom message" }) ) -const decode = Schema.decodeUnknownEither(MyString) +const decode = Schema.decodeUnknownSync(MyString) -console.log(decode(null)) // "String custom message" -console.log(decode("")) // "minLength custom message" -console.log(decode("abc")) // "maxLength custom message" +try { + decode(null) +} catch (e: any) { + console.log(e.message) // String custom message +} + +try { + decode("") +} catch (e: any) { + console.log(e.message) // minLength custom message +} + +try { + decode("abc") +} catch (e: any) { + console.log(e.message) // maxLength custom message +} ``` You have the option to change the default behavior by setting the `override` flag to `true`. This is useful when you want to create a single comprehensive custom message that describes the required properties of a valid value without displaying default messages. @@ -5450,11 +5604,25 @@ const MyString = Schema.String.pipe( message: () => ({ message: "my custom message", override: true }) }) -const decode = Schema.decodeUnknownEither(MyString) +const decode = Schema.decodeUnknownSync(MyString) + +try { + decode(null) +} catch (e: any) { + console.log(e.message) // my custom message +} + +try { + decode("") +} catch (e: any) { + console.log(e.message) // my custom message +} -console.log(decode(null)) // "my custom message" -console.log(decode("")) // "my custom message" -console.log(decode("abc")) // "my custom message" +try { + decode("abc") +} catch (e: any) { + console.log(e.message) // my custom message +} ``` #### Transformations @@ -5482,11 +5650,25 @@ const IntFromString = Schema.transformOrFail( // This message is displayed only if the input cannot be converted to a number .annotations({ message: () => "please enter a parseable string" }) -const decode = Schema.decodeUnknownEither(IntFromString) +const decode = Schema.decodeUnknownSync(IntFromString) + +try { + decode(null) +} catch (e: any) { + console.log(e.message) // please enter a string +} + +try { + decode("1.2") +} catch (e: any) { + console.log(e.message) // please enter an integer +} -console.log(decode(null)) // "please enter a string" -console.log(decode("1.2")) // "please enter an integer" -console.log(decode("not a number")) // "please enter a parseable string" +try { + decode("not a number") +} catch (e: any) { + console.log(e.message) // please enter a parseable string +} ``` #### Compound Schemas @@ -5503,8 +5685,9 @@ const schema = Schema.Struct({ Schema.Struct({ id: Schema.String, text: pipe( - Schema.String, - Schema.message(() => "error_invalid_outcome_type"), + Schema.String.annotations({ + message: () => "error_invalid_outcome_type" + }), Schema.minLength(1, { message: () => "error_required_field" }), Schema.maxLength(50, { message: () => "error_max_length_field" }) ) @@ -5519,7 +5702,7 @@ Schema.decodeUnknownSync(schema, { errors: "all" })({ }) /* throws -Error: { outcomes: an array of at least 1 items } +ParseError: { readonly outcomes: an array of at least 1 items } └─ ["outcomes"] └─ error_min_length_field */ @@ -5533,17 +5716,17 @@ Schema.decodeUnknownSync(schema, { errors: "all" })({ }) /* throws -Error: { outcomes: an array of at least 1 items } +ParseError: { readonly outcomes: an array of at least 1 items } └─ ["outcomes"] └─ an array of at least 1 items └─ From side refinement failure - └─ ReadonlyArray<{ id: string; text: a string at most 50 character(s) long }> + └─ ReadonlyArray<{ readonly id: string; readonly text: a string at most 50 character(s) long }> ├─ [0] - │ └─ { id: string; text: a string at most 50 character(s) long } + │ └─ { readonly id: string; readonly text: a string at most 50 character(s) long } │ └─ ["text"] │ └─ error_required_field └─ [2] - └─ { id: string; text: a string at most 50 character(s) long } + └─ { readonly id: string; readonly text: a string at most 50 character(s) long } └─ ["text"] └─ error_max_length_field */ diff --git a/packages/schema/src/AST.ts b/packages/schema/src/AST.ts index 1a7ed6bc8a..46561b26be 100644 --- a/packages/schema/src/AST.ts +++ b/packages/schema/src/AST.ts @@ -78,9 +78,7 @@ export const TypeAnnotationId = Symbol.for("@effect/schema/annotation/Type") * @category annotations * @since 0.67.0 */ -export type MessageAnnotation = ( - issue: ParseIssue -) => string | Effect | { +export type MessageAnnotation = (issue: ParseIssue) => string | Effect | { readonly message: string | Effect readonly override: boolean } diff --git a/packages/schema/src/Schema.ts b/packages/schema/src/Schema.ts index 8fc4fc21bf..0efb701ac9 100644 --- a/packages/schema/src/Schema.ts +++ b/packages/schema/src/Schema.ts @@ -4990,15 +4990,23 @@ export class BigIntFromNumber extends transformOrFail( const redactedArbitrary = (value: LazyArbitrary): LazyArbitrary> => (fc) => value(fc).map((x) => redacted_.make(x)) -const redactedParse = ( +const toComposite = ( + eff: Effect.Effect, + onSuccess: (a: A) => B, + ast: AST.AST, + actual: unknown +): Effect.Effect => + ParseResult.mapBoth(eff, { + onFailure: (e) => new ParseResult.Composite(ast, actual, e), + onSuccess + }) + +const redactedParse = ( decodeUnknown: ParseResult.DecodeUnknown ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => redacted_.isRedacted(u) ? - ParseResult.mapBoth(decodeUnknown(redacted_.value(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: redacted_.make - }) : + toComposite(decodeUnknown(redacted_.value(u), options), redacted_.make, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -5706,12 +5714,12 @@ const optionPretty = (value: pretty_.Pretty): pretty_.Pretty(decodeUnknown: ParseResult.DecodeUnknown): ParseResult.DeclarationDecodeUnknown, R> => + (decodeUnknown: ParseResult.DecodeUnknown): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => option_.isOption(u) ? option_.isNone(u) ? ParseResult.succeed(option_.none()) - : ParseResult.map(decodeUnknown(u.value, options), option_.some) + : toComposite(decodeUnknown(u.value, options), option_.some, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -5945,8 +5953,8 @@ const eitherParse = ( (u, options, ast) => either_.isEither(u) ? either_.match(u, { - onLeft: (left) => ParseResult.map(decodeUnknownLeft(left, options), either_.left), - onRight: (right) => ParseResult.map(parseRight(right, options), either_.right) + onLeft: (left) => toComposite(decodeUnknownLeft(left, options), either_.left, ast, u), + onRight: (right) => toComposite(parseRight(right, options), either_.right, ast, u) }) : ParseResult.fail(new ParseResult.Type(ast, u)) @@ -6100,10 +6108,7 @@ const readonlyMapParse = ( ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => Predicate.isMap(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(u.entries()), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: (as) => new Map(as) - }) + toComposite(decodeUnknown(Array.from(u.entries()), options), (as) => new Map(as), ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -6246,15 +6251,12 @@ const readonlySetEquivalence = ( return Equivalence.make((a, b) => arrayEquivalence(Array.from(a.values()), Array.from(b.values()))) } -const readonlySetParse = ( +const readonlySetParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => Predicate.isSet(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(u.values()), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: (as) => new Set(as) - }) + toComposite(decodeUnknown(Array.from(u.values()), options), (as) => new Set(as), ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -6712,17 +6714,14 @@ const chunkArbitrary = (item: LazyArbitrary): LazyArbitrary(item: pretty_.Pretty): pretty_.Pretty> => (c) => `Chunk(${chunk_.toReadonlyArray(c).map(item).join(", ")})` -const chunkParse = ( +const chunkParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => chunk_.isChunk(u) ? chunk_.isEmpty(u) ? ParseResult.succeed(chunk_.empty()) - : ParseResult.mapBoth(decodeUnknown(chunk_.toReadonlyArray(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: chunk_.fromIterable - }) + : toComposite(decodeUnknown(chunk_.toReadonlyArray(u), options), chunk_.fromIterable, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -6803,15 +6802,12 @@ const nonEmptyChunkArbitrary = (item: LazyArbitrary): LazyArbitrary(item: pretty_.Pretty): pretty_.Pretty> => (c) => `NonEmptyChunk(${chunk_.toReadonlyArray(c).map(item).join(", ")})` -const nonEmptyChunkParse = ( +const nonEmptyChunkParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => chunk_.isChunk(u) && chunk_.isNonEmpty(u) - ? ParseResult.mapBoth(decodeUnknown(chunk_.toReadonlyArray(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: chunk_.unsafeFromNonEmptyArray - }) + ? toComposite(decodeUnknown(chunk_.toReadonlyArray(u), options), chunk_.unsafeFromNonEmptyArray, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -6878,10 +6874,7 @@ const dataParse = > | ReadonlyArray => (u, options, ast) => Equal.isEqual(u) ? - ParseResult.mapBoth(decodeUnknown(u, options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: toData - }) + toComposite(decodeUnknown(u, options), toData, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -7747,15 +7740,12 @@ const causePretty = (error: pretty_.Pretty): pretty_.Pretty( +const causeParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => cause_.isCause(u) ? - ParseResult.mapBoth(decodeUnknown(causeEncode(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: causeDecode - }) + toComposite(decodeUnknown(causeEncode(u), options), causeDecode, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -7971,8 +7961,8 @@ const exitParse = ( (u, options, ast) => exit_.isExit(u) ? exit_.match(u, { - onFailure: (cause) => ParseResult.map(decodeUnknownCause(cause, options), exit_.failCause), - onSuccess: (value) => ParseResult.map(decodeUnknownValue(value, options), exit_.succeed) + onFailure: (cause) => toComposite(decodeUnknownCause(cause, options), exit_.failCause, ast, u), + onSuccess: (value) => toComposite(decodeUnknownValue(value, options), exit_.succeed, ast, u) }) : ParseResult.fail(new ParseResult.Type(ast, u)) @@ -8073,15 +8063,12 @@ const hashSetEquivalence = ( return Equivalence.make((a, b) => arrayEquivalence(Array.from(a), Array.from(b))) } -const hashSetParse = ( +const hashSetParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => hashSet_.isHashSet(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: hashSet_.fromIterable - }) + toComposite(decodeUnknown(Array.from(u), options), hashSet_.fromIterable, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -8177,10 +8164,7 @@ const hashMapParse = ( ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => hashMap_.isHashMap(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: hashMap_.fromIterable - }) + toComposite(decodeUnknown(Array.from(u), options), hashMap_.fromIterable, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -8262,15 +8246,12 @@ const listEquivalence = ( return Equivalence.make((a, b) => arrayEquivalence(Array.from(a), Array.from(b))) } -const listParse = ( +const listParse = ( decodeUnknown: ParseResult.DecodeUnknown, R> ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => list_.isList(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(u), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: list_.fromIterable - }) + toComposite(decodeUnknown(Array.from(u), options), list_.fromIterable, ast, u) : ParseResult.fail(new ParseResult.Type(ast, u)) /** @@ -8341,16 +8322,18 @@ const sortedSetArbitrary = const sortedSetPretty = (item: pretty_.Pretty): pretty_.Pretty> => (set) => `new SortedSet([${Array.from(sortedSet_.values(set)).map((a) => item(a)).join(", ")}])` -const sortedSetParse = ( +const sortedSetParse = ( decodeUnknown: ParseResult.DecodeUnknown, R>, ord: Order.Order ): ParseResult.DeclarationDecodeUnknown, R> => (u, options, ast) => sortedSet_.isSortedSet(u) ? - ParseResult.mapBoth(decodeUnknown(Array.from(sortedSet_.values(u)), options), { - onFailure: (e) => new ParseResult.Composite(ast, u, e), - onSuccess: (as): sortedSet_.SortedSet => sortedSet_.fromIterable(as, ord) - }) + toComposite( + decodeUnknown(Array.from(sortedSet_.values(u)), options), + (as): sortedSet_.SortedSet => sortedSet_.fromIterable(as, ord), + ast, + u + ) : ParseResult.fail(new ParseResult.Type(ast, u)) /** diff --git a/packages/schema/src/TreeFormatter.ts b/packages/schema/src/TreeFormatter.ts index 299084c067..05eaf3c960 100644 --- a/packages/schema/src/TreeFormatter.ts +++ b/packages/schema/src/TreeFormatter.ts @@ -82,35 +82,17 @@ const formatRefinementKind = (kind: ParseResult.Refinement["kind"]): string => { } } -const getInnerMessage = ( - issue: ParseResult.ParseIssue -): Effect.Effect => { - switch (issue._tag) { - case "Refinement": { - if (issue.kind === "From") { - return getMessage(issue.issue) - } - break - } - case "Transformation": { - return getMessage(issue.issue) - } - } - return Option.none() -} +const getAnnotated = (issue: ParseResult.ParseIssue): Option.Option => + "ast" in issue ? Option.some(issue.ast) : Option.none() -const getAnnotated = (issue: ParseResult.ParseIssue): Option.Option => { - if ("ast" in issue) { - return Option.some(issue.ast) - } - return Option.none() +interface CurrentMessage { + readonly message: string + readonly override: boolean } -const getCurrentMessage: ( - issue: ParseResult.ParseIssue -) => Effect.Effect<{ message: string; override: boolean }, Cause.NoSuchElementException> = ( +const getCurrentMessage = ( issue: ParseResult.ParseIssue -) => +): Effect.Effect => getAnnotated(issue).pipe( Option.flatMap(AST.getMessageAnnotation), Effect.flatMap((annotation) => { @@ -125,28 +107,30 @@ const getCurrentMessage: ( }) ) +const createParseIssueGuard = + (tag: T) => + (issue: ParseResult.ParseIssue): issue is Extract => issue._tag === tag + +const isComposite = createParseIssueGuard("Composite") +const isRefinement = createParseIssueGuard("Refinement") +const isTransformation = createParseIssueGuard("Transformation") + /** @internal */ export const getMessage: ( issue: ParseResult.ParseIssue -) => Effect.Effect = (issue: ParseResult.ParseIssue) => { - const current = getCurrentMessage(issue) - return getInnerMessage(issue).pipe( - Effect.flatMap((inner) => Effect.map(current, (current) => current.override ? current.message : inner)), - Effect.catchAll(() => - Effect.flatMap(current, (current) => { - if ( - !current.override && ( - (issue._tag === "Refinement" && issue.kind !== "Predicate") || - (issue._tag === "Transformation" && issue.kind !== "Transformation") - ) - ) { - return Option.none() - } - return Effect.succeed(current.message) - }) - ) +) => Effect.Effect = (issue: ParseResult.ParseIssue) => + getCurrentMessage(issue).pipe( + Effect.flatMap((currentMessage) => { + const useInnerMessage = !currentMessage.override && ( + isComposite(issue) || + (isRefinement(issue) && issue.kind === "From") || + (isTransformation(issue) && issue.kind !== "Transformation") + ) + return useInnerMessage + ? isTransformation(issue) || isRefinement(issue) ? getMessage(issue.issue) : Option.none() + : Effect.succeed(currentMessage.message) + }) ) -} const getParseIssueTitleAnnotation = (issue: ParseResult.ParseIssue): Option.Option => getAnnotated(issue).pipe( diff --git a/packages/schema/test/Formatter.test.ts b/packages/schema/test/Formatter.test.ts index 159633c618..13468a7add 100644 --- a/packages/schema/test/Formatter.test.ts +++ b/packages/schema/test/Formatter.test.ts @@ -22,80 +22,6 @@ const expectIssues = (schema: S.Schema, input: unknown, issues: Arra } describe("Formatter", () => { - describe("missing message", () => { - it("Struct", async () => { - const schema = S.Struct({ - a: S.propertySignature(S.String).annotations({ - description: "my description", - missingMessage: () => "my missing message" - }) - }) - const input = {} - await Util.expectDecodeUnknownFailure( - schema, - input, - `{ readonly a: string } -└─ ["a"] - └─ my missing message` - ) - expectIssues(schema, input, [{ - _tag: "Missing", - path: ["a"], - message: "my missing message" - }]) - }) - - describe("Tuple", () => { - it("e", async () => { - const schema = S.make( - new AST.TupleType( - [ - new AST.OptionalType(AST.stringKeyword, false, { - [AST.MissingMessageAnnotationId]: () => "my missing message" - }) - ], - [], - true - ) - ) - const input: Array = [] - await Util.expectDecodeUnknownFailure( - schema, - input, - `readonly [string] -└─ [0] - └─ my missing message` - ) - expectIssues(schema, input, [{ - _tag: "Missing", - path: [0], - message: "my missing message" - }]) - }) - - it("r + e", async () => { - const schema = S.Tuple( - [], - S.String, - S.element(S.String).annotations({ [AST.MissingMessageAnnotationId]: () => "my missing message" }) - ) - const input: Array = [] - await Util.expectDecodeUnknownFailure( - schema, - input, - `readonly [...string[], string] -└─ [0] - └─ my missing message` - ) - expectIssues(schema, input, [{ - _tag: "Missing", - path: [0], - message: "my missing message" - }]) - }) - }) - }) - describe("Forbidden", () => { it("default message", () => { const schema = Util.effectify(S.String) @@ -182,7 +108,7 @@ describe("Formatter", () => { }]) }) - it("default message with identifier", async () => { + it("default message with parent identifier", async () => { const schema = S.Struct({ a: S.String }).annotations({ identifier: "identifier" }) const input = {} await Util.expectDecodeUnknownFailure( @@ -199,22 +125,7 @@ describe("Formatter", () => { }]) }) - it("custom message (override=false)", async () => { - const schema = S.Struct({ a: S.String }).annotations({ message: () => "custom message" }) - const input = {} - await Util.expectDecodeUnknownFailure( - schema, - input, - "custom message" - ) - expectIssues(schema, input, [{ - _tag: "Composite", - path: [], - message: "custom message" - }]) - }) - - it("custom message (override=true)", async () => { + it("parent custom message with override=true", async () => { const schema = S.Struct({ a: S.String }).annotations({ message: () => ({ message: "custom message", override: true }) }) @@ -230,6 +141,80 @@ describe("Formatter", () => { message: "custom message" }]) }) + + describe("missing message", () => { + it("Struct", async () => { + const schema = S.Struct({ + a: S.propertySignature(S.String).annotations({ + description: "my description", + missingMessage: () => "my missing message" + }) + }) + const input = {} + await Util.expectDecodeUnknownFailure( + schema, + input, + `{ readonly a: string } +└─ ["a"] + └─ my missing message` + ) + expectIssues(schema, input, [{ + _tag: "Missing", + path: ["a"], + message: "my missing message" + }]) + }) + + describe("Tuple", () => { + it("e", async () => { + const schema = S.make( + new AST.TupleType( + [ + new AST.OptionalType(AST.stringKeyword, false, { + [AST.MissingMessageAnnotationId]: () => "my missing message" + }) + ], + [], + true + ) + ) + const input: Array = [] + await Util.expectDecodeUnknownFailure( + schema, + input, + `readonly [string] +└─ [0] + └─ my missing message` + ) + expectIssues(schema, input, [{ + _tag: "Missing", + path: [0], + message: "my missing message" + }]) + }) + + it("r + e", async () => { + const schema = S.Tuple( + [], + S.String, + S.element(S.String).annotations({ [AST.MissingMessageAnnotationId]: () => "my missing message" }) + ) + const input: Array = [] + await Util.expectDecodeUnknownFailure( + schema, + input, + `readonly [...string[], string] +└─ [0] + └─ my missing message` + ) + expectIssues(schema, input, [{ + _tag: "Missing", + path: [0], + message: "my missing message" + }]) + }) + }) + }) }) describe("Unexpected", () => { @@ -251,7 +236,7 @@ describe("Formatter", () => { }]) }) - it("default message with identifier", async () => { + it("default message with parent identifier", async () => { const schema = S.Struct({ a: S.String }).annotations({ identifier: "identifier" }) const input = { a: "a", b: 1 } await Util.expectDecodeUnknownFailure( @@ -269,23 +254,7 @@ describe("Formatter", () => { }]) }) - it("custom message (override=false)", async () => { - const schema = S.Struct({ a: S.String }).annotations({ message: () => "custom message" }) - const input = { a: "a", b: 1 } - await Util.expectDecodeUnknownFailure( - schema, - input, - "custom message", - Util.onExcessPropertyError - ) - expectIssues(schema, input, [{ - _tag: "Composite", - path: [], - message: "custom message" - }]) - }) - - it("custom message (override=true)", async () => { + it("parent custom message with override=true", async () => { const schema = S.Struct({ a: S.String }).annotations({ message: () => ({ message: "custom message", override: true }) }) @@ -306,22 +275,22 @@ describe("Formatter", () => { describe("Declaration", () => { it("default message", async () => { - const schema = S.instanceOf(File) + const schema = S.OptionFromSelf(S.String) const input = null await Util.expectDecodeUnknownFailure( schema, input, - "Expected File, actual null" + "Expected Option, actual null" ) expectIssues(schema, input, [{ _tag: "Type", path: [], - message: `Expected File, actual null` + message: "Expected Option, actual null" }]) }) it("default message with identifier", async () => { - const schema = S.instanceOf(File).annotations({ identifier: "identifier" }) + const schema = S.OptionFromSelf(S.String).annotations({ identifier: "identifier" }) const input = null await Util.expectDecodeUnknownFailure( schema, @@ -336,34 +305,33 @@ describe("Formatter", () => { }) it("custom message (override=false)", async () => { - const schema = S.instanceOf(File).annotations({ message: () => "custom message" }) - const input = null + const schema = S.OptionFromSelf(S.String).annotations({ message: () => "custom message" }) + const input = Option.some(1) await Util.expectDecodeUnknownFailure( schema, input, - "custom message", - Util.onExcessPropertyError + `Option +└─ Expected string, actual 1` ) expectIssues(schema, input, [{ _tag: "Type", path: [], - message: "custom message" + message: "Expected string, actual 1" }]) }) it("custom message (override=true)", async () => { - const schema = S.instanceOf(File).annotations({ + const schema = S.OptionFromSelf(S.String).annotations({ message: () => ({ message: "custom message", override: true }) }) - const input = null + const input = Option.some(1) await Util.expectDecodeUnknownFailure( schema, input, - "custom message", - Util.onExcessPropertyError + "custom message" ) expectIssues(schema, input, [{ - _tag: "Type", + _tag: "Composite", path: [], message: "custom message" }]) @@ -873,8 +841,173 @@ describe("Formatter", () => { }) }) + describe("Suspend", () => { + it("outer", async () => { + type A = readonly [number, A | null] + const schema: S.Schema = S.suspend( // intended outer suspend + () => S.Tuple(S.Number, S.Union(schema, S.Literal(null))) + ) + + await Util.expectDecodeUnknownFailure( + schema, + null, + `Expected readonly [number, | null], actual null` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1, undefined], + `readonly [number, | null] +└─ [1] + └─ | null + ├─ Expected readonly [number, | null], actual undefined + └─ Expected null, actual undefined` + ) + }) + + it("inner", async () => { + type A = readonly [number, A | null] + const schema: S.Schema = S.Tuple( + S.Number, + S.Union(S.suspend(() => schema), S.Literal(null)) + ) + + await Util.expectDecodeUnknownFailure( + schema, + null, + `Expected readonly [number, | null], actual null` + ) + await Util.expectDecodeUnknownFailure( + schema, + [1, undefined], + `readonly [number, | null] +└─ [1] + └─ | null + ├─ Expected readonly [number, | null], actual undefined + └─ Expected null, actual undefined` + ) + }) + }) + + describe("Union", () => { + it("default message", async () => { + const schema = S.Union(S.String, S.Number) + const input = null + await Util.expectDecodeUnknownFailure( + schema, + input, + `string | number +├─ Expected string, actual null +└─ Expected number, actual null` + ) + expectIssues(schema, input, [{ + _tag: "Type", + path: [], + message: "Expected string, actual null" + }, { + _tag: "Type", + path: [], + message: "Expected number, actual null" + }]) + }) + + it("default message with identifier", async () => { + const schema = S.Union(S.String, S.Number).annotations({ identifier: "identifier" }) + const input = null + await Util.expectDecodeUnknownFailure( + schema, + input, + `identifier +├─ Expected string, actual null +└─ Expected number, actual null` + ) + expectIssues(schema, input, [{ + _tag: "Type", + path: [], + message: "Expected string, actual null" + }, { + _tag: "Type", + path: [], + message: "Expected number, actual null" + }]) + }) + + it("parent custom message with override=false", async () => { + const schema = S.Union(S.String, S.Number).annotations({ + message: () => "custom message" + }) + const input = null + await Util.expectDecodeUnknownFailure( + schema, + input, + `string | number +├─ Expected string, actual null +└─ Expected number, actual null` + ) + expectIssues(schema, input, [{ + _tag: "Type", + path: [], + message: "Expected string, actual null" + }, { + _tag: "Type", + path: [], + message: "Expected number, actual null" + }]) + }) + + it("parent custom message with override=true", async () => { + const schema = S.Union(S.String, S.Number).annotations({ + message: () => ({ message: "custom message", override: true }) + }) + const input = null + await Util.expectDecodeUnknownFailure( + schema, + input, + "custom message" + ) + expectIssues(schema, input, [{ + _tag: "Composite", + path: [], + message: "custom message" + }]) + }) + }) + + describe("Tuple", () => { + it("parent custom message with override=false", async () => { + const schema = S.Tuple(S.String).annotations({ message: () => "custom message" }) + const input1 = [1] + await Util.expectDecodeUnknownFailure( + schema, + input1, + `readonly [string] +└─ [0] + └─ Expected string, actual 1` + ) + expectIssues(schema, input1, [{ + _tag: "Type", + path: [0], + message: "Expected string, actual 1" + }]) + }) + + it("parent custom message with override=true", async () => { + const schema = S.Tuple(S.String).annotations({ message: () => ({ message: "custom message", override: true }) }) + const input1 = [1] + await Util.expectDecodeUnknownFailure( + schema, + input1, + "custom message" + ) + expectIssues(schema, input1, [{ + _tag: "Composite", + path: [], + message: "custom message" + }]) + }) + }) + describe("Struct", () => { - it("custom message (override=false)", async () => { + it("parent custom message with override=false", async () => { const schema = S.Struct({ as: pipe( S.Array( @@ -888,17 +1021,17 @@ describe("Formatter", () => { ).annotations({ identifier: "C" }), S.minItems(1, { message: () => "minItems" }) ).annotations({ identifier: "B" }) - }).annotations({ identifier: "A" }) + }).annotations({ identifier: "A", message: () => "custom message" }) const input1 = null await Util.expectDecodeUnknownFailure( schema, input1, - "Expected A, actual null" + "custom message" ) expectIssues(schema, input1, [{ _tag: "Type", path: [], - message: "Expected A, actual null" + message: "custom message" }]) const input2 = { as: [] } @@ -975,130 +1108,40 @@ describe("Formatter", () => { message: "maxLength" }]) }) - }) - - describe("Member", () => { - it("default message", async () => { - const schema = S.Union(S.String, S.Number) - const input = null - await Util.expectDecodeUnknownFailure( - schema, - input, - `string | number -├─ Expected string, actual null -└─ Expected number, actual null` - ) - expectIssues(schema, input, [{ - _tag: "Type", - path: [], - message: "Expected string, actual null" - }, { - _tag: "Type", - path: [], - message: "Expected number, actual null" - }]) - }) - }) - - describe("suspend", () => { - it("outer", async () => { - type A = readonly [number, A | null] - const schema: S.Schema = S.suspend( // intended outer suspend - () => S.Tuple(S.Number, S.Union(schema, S.Literal(null))) - ) - - await Util.expectDecodeUnknownFailure( - schema, - null, - `Expected readonly [number, | null], actual null` - ) - await Util.expectDecodeUnknownFailure( - schema, - [1, undefined], - `readonly [number, | null] -└─ [1] - └─ | null - ├─ Expected readonly [number, | null], actual undefined - └─ Expected null, actual undefined` - ) - }) - - it("inner", async () => { - type A = readonly [number, A | null] - const schema: S.Schema = S.Tuple( - S.Number, - S.Union(S.suspend(() => schema), S.Literal(null)) - ) - - await Util.expectDecodeUnknownFailure( - schema, - null, - `Expected readonly [number, | null], actual null` - ) - await Util.expectDecodeUnknownFailure( - schema, - [1, undefined], - `readonly [number, | null] -└─ [1] - └─ | null - ├─ Expected readonly [number, | null], actual undefined - └─ Expected null, actual undefined` - ) - }) - }) - - describe("Union", () => { - it("default message", async () => { - const schema = S.Union(S.String, S.Number) - const input = null - await Util.expectDecodeUnknownFailure( - schema, - input, - `string | number -├─ Expected string, actual null -└─ Expected number, actual null` - ) - expectIssues(schema, input, [{ - _tag: "Type", - path: [], - message: "Expected string, actual null" - }, { - _tag: "Type", - path: [], - message: "Expected number, actual null" - }]) - }) - it("default message with identifier", async () => { - const schema = S.Union(S.String, S.Number).annotations({ identifier: "identifier" }) - const input = null + it("parent custom message with override=true", async () => { + const schema = S.Struct({ + as: pipe( + S.Array( + S.Struct({ + b: pipe( + S.String.annotations({ message: () => "type" }), + S.minLength(1, { message: () => "minLength" }), + S.maxLength(2, { message: () => "maxLength" }) + ) + }) + ).annotations({ identifier: "C" }), + S.minItems(1, { message: () => "minItems" }) + ).annotations({ identifier: "B" }) + }).annotations({ identifier: "A", message: () => ({ message: "custom message", override: true }) }) + const input1 = null await Util.expectDecodeUnknownFailure( schema, - input, - `identifier -├─ Expected string, actual null -└─ Expected number, actual null` + input1, + "custom message" ) - expectIssues(schema, input, [{ - _tag: "Type", - path: [], - message: "Expected string, actual null" - }, { + expectIssues(schema, input1, [{ _tag: "Type", path: [], - message: "Expected number, actual null" + message: "custom message" }]) - }) - - it("custom message", async () => { - const schema = S.Union(S.String, S.Number).annotations({ message: () => "custom message" }) - const input = null + const input2 = { as: [] } await Util.expectDecodeUnknownFailure( schema, - input, + input2, "custom message" ) - expectIssues(schema, input, [{ + expectIssues(schema, input2, [{ _tag: "Composite", path: [], message: "custom message" @@ -1126,7 +1169,7 @@ describe("handle identifiers", () => { ) }) - describe("suspend", () => { + describe("Suspend", () => { it("outer", async () => { type A = readonly [number, A | null] const schema: S.Schema = S.suspend( // intended outer suspend diff --git a/packages/schema/test/Schema/Either/EitherFromSelf.test.ts b/packages/schema/test/Schema/Either/EitherFromSelf.test.ts index f03d7f1469..ca9e5b343b 100644 --- a/packages/schema/test/Schema/Either/EitherFromSelf.test.ts +++ b/packages/schema/test/Schema/Either/EitherFromSelf.test.ts @@ -28,9 +28,33 @@ describe("EitherFromSelf", () => { }) it("decoding", async () => { - const schema = S.EitherFromSelf({ left: S.String, right: S.NumberFromString }) - await Util.expectDecodeUnknownSuccess(schema, E.left("a"), E.left("a")) - await Util.expectDecodeUnknownSuccess(schema, E.right("1"), E.right(1)) + const schema = S.EitherFromSelf({ left: S.NumberFromString, right: Util.BooleanFromLiteral }) + await Util.expectDecodeUnknownSuccess(schema, E.left("1"), E.left(1)) + await Util.expectDecodeUnknownSuccess(schema, E.right("true"), E.right(true)) + + await Util.expectDecodeUnknownFailure( + schema, + null, + `Expected Either<("true" | "false" <-> boolean), NumberFromString>, actual null` + ) + await Util.expectDecodeUnknownFailure( + schema, + E.right(""), + `Either<("true" | "false" <-> boolean), NumberFromString> +└─ ("true" | "false" <-> boolean) + └─ Encoded side transformation failure + └─ "true" | "false" + ├─ Expected "true", actual "" + └─ Expected "false", actual ""` + ) + await Util.expectDecodeUnknownFailure( + schema, + E.left("a"), + `Either<("true" | "false" <-> boolean), NumberFromString> +└─ NumberFromString + └─ Transformation process failure + └─ Expected NumberFromString, actual "a"` + ) }) it("pretty", () => { diff --git a/packages/schema/test/Schema/Exit/ExitFromSelf.test.ts b/packages/schema/test/Schema/Exit/ExitFromSelf.test.ts index 45be4daf0e..fc55b415a9 100644 --- a/packages/schema/test/Schema/Exit/ExitFromSelf.test.ts +++ b/packages/schema/test/Schema/Exit/ExitFromSelf.test.ts @@ -1,9 +1,44 @@ import * as S from "@effect/schema/Schema" import * as Util from "@effect/schema/test/TestUtils" +import * as E from "effect/Exit" import { describe, it } from "vitest" describe("ExitFromSelf", () => { it("arbitrary", () => { Util.expectArbitrary(S.ExitFromSelf({ failure: S.String, success: S.Number })) }) + + it("decoding", async () => { + const schema = S.ExitFromSelf({ failure: S.NumberFromString, success: Util.BooleanFromLiteral }) + await Util.expectDecodeUnknownSuccess(schema, E.fail("1"), E.fail(1)) + await Util.expectDecodeUnknownSuccess(schema, E.succeed("true"), E.succeed(true)) + + await Util.expectDecodeUnknownFailure( + schema, + null, + `Expected Exit<("true" | "false" <-> boolean), NumberFromString>, actual null` + ) + await Util.expectDecodeUnknownFailure( + schema, + E.succeed(""), + `Exit<("true" | "false" <-> boolean), NumberFromString> +└─ ("true" | "false" <-> boolean) + └─ Encoded side transformation failure + └─ "true" | "false" + ├─ Expected "true", actual "" + └─ Expected "false", actual ""` + ) + await Util.expectDecodeUnknownFailure( + schema, + E.fail("a"), + `Exit<("true" | "false" <-> boolean), NumberFromString> +└─ Cause + └─ CauseEncoded + └─ { readonly _tag: "Fail"; readonly error: NumberFromString } + └─ ["error"] + └─ NumberFromString + └─ Transformation process failure + └─ Expected NumberFromString, actual "a"` + ) + }) }) diff --git a/packages/schema/test/TestUtils.ts b/packages/schema/test/TestUtils.ts index 9c1078a54a..772c9d6614 100644 --- a/packages/schema/test/TestUtils.ts +++ b/packages/schema/test/TestUtils.ts @@ -361,3 +361,8 @@ export const expectAssertsFailure = ( ) => { expect(() => S.asserts(schema, options)(input)).toThrow(new Error(message)) } + +export const BooleanFromLiteral = S.transform(S.Literal("true", "false"), S.Boolean, { + decode: (l) => l === "true", + encode: (b) => b ? "true" : "false" +})