Skip to content

Commit

Permalink
Schema: add ParseIssueTitle annotation, closes #2482 (#2483)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Apr 5, 2024
1 parent a4032fa commit 42b3651
Show file tree
Hide file tree
Showing 7 changed files with 357 additions and 102 deletions.
103 changes: 103 additions & 0 deletions .changeset/lovely-pianos-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
"@effect/schema": patch
---

Add `ParseIssueTitle` annotation, closes #2482

When a decoding or encoding operation fails, it's useful to have additional details in the default error message returned by `TreeFormatter` to understand exactly which value caused the operation to fail. To achieve this, you can set an annotation that depends on the value undergoing the operation and can return an excerpt of it, making it easier to identify the problematic value. A common scenario is when the entity being validated has an `id` field. The `ParseIssueTitle` annotation facilitates this kind of analysis during error handling.

The type of the annotation is:

```ts
export type ParseIssueTitleAnnotation = (
issue: ParseIssue
) => string | undefined;
```

If you set this annotation on a schema and the provided function returns a `string`, then that string is used as the title by `TreeFormatter`, unless a `message` annotation (which has the highest priority) has also been set. If the function returns `undefined`, then the default title used by `TreeFormatter` is determined with the following priorities:

- `identifier`
- `title`
- `description`
- `ast.toString()`

**Example**

```ts
import type { ParseIssue } from "@effect/schema/ParseResult";
import * as S from "@effect/schema/Schema";

const getOrderItemId = ({ actual }: ParseIssue) => {
if (S.is(S.struct({ id: S.string }))(actual)) {
return `OrderItem with id: ${actual.id}`;
}
};

const OrderItem = S.struct({
id: S.string,
name: S.string,
price: S.number,
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId,
});

const getOrderId = ({ actual }: ParseIssue) => {
if (S.is(S.struct({ id: S.number }))(actual)) {
return `Order with id: ${actual.id}`;
}
};

const Order = S.struct({
id: S.number,
name: S.string,
items: S.array(OrderItem),
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId,
});

const decode = S.decodeUnknownSync(Order, { errors: "all" });

// No id available, so the `identifier` annotation is used as the title
decode({});
/*
throws
Error: Order
├─ ["id"]
│ └─ is missing
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/

// An id is available, so the `parseIssueTitle` annotation is used as the title
decode({ id: 1 });
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/

decode({ id: 1, items: [{ id: "22b", price: "100" }] });
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ ReadonlyArray<OrderItem>
└─ [0]
└─ OrderItem with id: 22b
├─ ["name"]
│ └─ is missing
└─ ["price"]
└─ Expected a number, actual "100"
*/
```

In the examples above, we can see how the `parseIssueTitle` annotation helps provide meaningful error messages when decoding fails.
100 changes: 100 additions & 0 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,106 @@ In this example, the tree error message is structured as follows:
- `["name"]` indicates the offending property, in this case, the `"name"` property.
- `is missing` represents the specific error for the `"name"` property.

#### ParseIssueTitle Annotation

When a decoding or encoding operation fails, it's useful to have additional details in the default error message returned by `TreeFormatter` to understand exactly which value caused the operation to fail. To achieve this, you can set an annotation that depends on the value undergoing the operation and can return an excerpt of it, making it easier to identify the problematic value. A common scenario is when the entity being validated has an `id` field. The `ParseIssueTitle` annotation facilitates this kind of analysis during error handling.

The type of the annotation is:

```ts
export type ParseIssueTitleAnnotation = (
issue: ParseIssue
) => string | undefined;
```

If you set this annotation on a schema and the provided function returns a `string`, then that string is used as the title by `TreeFormatter`, unless a `message` annotation (which has the highest priority) has also been set. If the function returns `undefined`, then the default title used by `TreeFormatter` is determined with the following priorities:

- `identifier`
- `title`
- `description`
- `ast.toString()`

**Example**

```ts
import type { ParseIssue } from "@effect/schema/ParseResult";
import * as S from "@effect/schema/Schema";

const getOrderItemId = ({ actual }: ParseIssue) => {
if (S.is(S.struct({ id: S.string }))(actual)) {
return `OrderItem with id: ${actual.id}`;
}
};

const OrderItem = S.struct({
id: S.string,
name: S.string,
price: S.number,
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId,
});

const getOrderId = ({ actual }: ParseIssue) => {
if (S.is(S.struct({ id: S.number }))(actual)) {
return `Order with id: ${actual.id}`;
}
};

const Order = S.struct({
id: S.number,
name: S.string,
items: S.array(OrderItem),
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId,
});

const decode = S.decodeUnknownSync(Order, { errors: "all" });

// No id available, so the `identifier` annotation is used as the title
decode({});
/*
throws
Error: Order
├─ ["id"]
│ └─ is missing
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/

// An id is available, so the `parseIssueTitle` annotation is used as the title
decode({ id: 1 });
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/

decode({ id: 1, items: [{ id: "22b", price: "100" }] });
/*
throws
Error: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ ReadonlyArray<OrderItem>
└─ [0]
└─ OrderItem with id: 22b
├─ ["name"]
│ └─ is missing
└─ ["price"]
└─ Expected a number, actual "100"
*/
```

In the examples above, we can see how the `parseIssueTitle` annotation helps provide meaningful error messages when decoding fails.

### ArrayFormatter

The `ArrayFormatter` is an alternative way to format errors, presenting them as an array of issues. Each issue contains properties such as `_tag`, `path`, and `message`.
Expand Down
18 changes: 18 additions & 0 deletions packages/schema/src/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ export const BatchingAnnotationId = Symbol.for("@effect/schema/annotation/Batchi
/** @internal */
export const SurrogateAnnotationId = Symbol.for("@effect/schema/annotation/Surrogate")

/**
* @category annotations
* @since 1.0.0
*/
export type ParseIssueTitleAnnotation = (issue: ParseIssue) => string | undefined

/**
* @category annotations
* @since 1.0.0
*/
export const ParseIssueTitleAnnotationId = Symbol.for("@effect/schema/annotation/ParseIssueTitle")

/**
* Used by:
*
Expand Down Expand Up @@ -307,6 +319,12 @@ export const getConcurrencyAnnotation = getAnnotation<ConcurrencyAnnotation>(Con
*/
export const getBatchingAnnotation = getAnnotation<BatchingAnnotation>(BatchingAnnotationId)

/**
* @category annotations
* @since 1.0.0
*/
export const getParseIssueTitleAnnotation = getAnnotation<ParseIssueTitleAnnotation>(ParseIssueTitleAnnotationId)

/** @internal */
export const getSurrogateAnnotation = getAnnotation<SurrogateAnnotation>(SurrogateAnnotationId)

Expand Down
81 changes: 34 additions & 47 deletions packages/schema/src/ArrayFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,71 +42,58 @@ export const formatErrorEffect = (error: ParseResult.ParseError): Effect.Effect<
*/
export const formatError = (error: ParseResult.ParseError): Array<Issue> => formatIssue(error.error)

const getArray = (
issue: ParseResult.ParseIssue,
path: ReadonlyArray<PropertyKey>,
onFailure: () => Effect.Effect<Array<Issue>>
) =>
Effect.matchEffect(TreeFormatter.getMessage(issue), {
onFailure,
onSuccess: (message) => Effect.succeed<Array<Issue>>([{ _tag: issue._tag, path, message }])
})

const go = (
e: ParseResult.ParseIssue | ParseResult.Missing | ParseResult.Unexpected,
path: ReadonlyArray<PropertyKey> = []
): Effect.Effect<Array<Issue>> => {
const _tag = e._tag
switch (_tag) {
case "Type":
return Effect.map(
TreeFormatter.formatTypeMessage(e),
(message) => [{ _tag, path, message }]
)
return Effect.map(TreeFormatter.formatTypeMessage(e), (message) => [{ _tag, path, message }])
case "Forbidden":
return Effect.succeed([{ _tag, path, message: TreeFormatter.formatForbiddenMessage(e) }])
case "Unexpected":
return Effect.succeed([{ _tag, path, message: `is unexpected, expected ${e.ast.toString(true)}` }])
case "Missing":
return Effect.succeed([{ _tag, path, message: "is missing" }])
case "Union":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () =>
Effect.map(
Effect.forEach(e.errors, (e) => {
switch (e._tag) {
case "Member":
return go(e.error, path)
default:
return go(e, path)
}
}),
ReadonlyArray.flatten
),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
return getArray(e, path, () =>
Effect.map(
Effect.forEach(e.errors, (e) => {
switch (e._tag) {
case "Member":
return go(e.error, path)
default:
return go(e, path)
}
}),
ReadonlyArray.flatten
))
case "TupleType":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () =>
Effect.map(
Effect.forEach(e.errors, (index) => go(index.error, [...path, index.index])),
ReadonlyArray.flatten
),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
return getArray(e, path, () =>
Effect.map(
Effect.forEach(e.errors, (index) => go(index.error, [...path, index.index])),
ReadonlyArray.flatten
))
case "TypeLiteral":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () =>
Effect.map(
Effect.forEach(e.errors, (key) => go(key.error, [...path, key.key])),
ReadonlyArray.flatten
),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
return getArray(e, path, () =>
Effect.map(
Effect.forEach(e.errors, (key) => go(key.error, [...path, key.key])),
ReadonlyArray.flatten
))
case "Transformation":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () => go(e.error, path),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
case "Refinement":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () => go(e.error, path),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
case "Declaration":
return Effect.matchEffect(TreeFormatter.getMessage(e), {
onFailure: () => go(e.error, path),
onSuccess: (message) => Effect.succeed([{ _tag, path, message }])
})
return getArray(e, path, () => go(e.error, path))
}
}
10 changes: 10 additions & 0 deletions packages/schema/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const toASTAnnotations = (
move("equivalence", equivalence_.EquivalenceHookId)
move("concurrency", AST.ConcurrencyAnnotationId)
move("batching", AST.BatchingAnnotationId)
move("parseIssueTitle", AST.ParseIssueTitleAnnotationId)

return out
}
Expand Down Expand Up @@ -2911,6 +2912,7 @@ export declare namespace Annotations {
) => Equivalence.Equivalence<A>
readonly concurrency?: AST.ConcurrencyAnnotation
readonly batching?: AST.BatchingAnnotation
readonly parseIssueTitle?: AST.ParseIssueTitleAnnotation
}

/**
Expand Down Expand Up @@ -3023,6 +3025,14 @@ export const concurrency =
export const batching = (batching: AST.BatchingAnnotation) => <S extends Annotable.All>(self: S): Annotable.Self<S> =>
self.annotations({ [AST.BatchingAnnotationId]: batching })

/**
* @category annotations
* @since 1.0.0
*/
export const parseIssueTitle =
(f: AST.ParseIssueTitleAnnotation) => <S extends Annotable.All>(self: S): Annotable.Self<S> =>
self.annotations({ [AST.ParseIssueTitleAnnotationId]: f })

type Rename<A, M> = {
[
K in keyof A as K extends keyof M ? M[K] extends PropertyKey ? M[K]
Expand Down
Loading

0 comments on commit 42b3651

Please # to comment.