Skip to content

Commit

Permalink
Enhanced Error Reporting for Discriminated Union Tuple Schemas (#3753)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Oct 8, 2024
1 parent 597b301 commit f02b354
Show file tree
Hide file tree
Showing 4 changed files with 257 additions and 5 deletions.
50 changes: 50 additions & 0 deletions .changeset/cyan-pillows-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@effect/schema": patch
---

Enhanced Error Reporting for Discriminated Union Tuple Schemas, closes #3752

Previously, irrelevant error messages were generated for each member of the union. Now, when a discriminator is present in the input, only the relevant member will trigger an error.

Before

```ts
import * as Schema from "@effect/schema/Schema"

const schema = Schema.Union(
Schema.Tuple(Schema.Literal("a"), Schema.String),
Schema.Tuple(Schema.Literal("b"), Schema.Number)
).annotations({ identifier: "MyUnion" })

console.log(Schema.decodeUnknownSync(schema)(["a", 0]))
/*
throws:
ParseError: MyUnion
├─ readonly ["a", string]
│ └─ [1]
│ └─ Expected string, actual 0
└─ readonly ["b", number]
└─ [0]
└─ Expected "b", actual "a"
*/
```

After

```ts
import * as Schema from "@effect/schema/Schema"

const schema = Schema.Union(
Schema.Tuple(Schema.Literal("a"), Schema.String),
Schema.Tuple(Schema.Literal("b"), Schema.Number)
).annotations({ identifier: "MyUnion" })

console.log(Schema.decodeUnknownSync(schema)(["a", 0]))
/*
throws:
ParseError: MyUnion
└─ readonly ["a", string]
└─ [1]
└─ Expected string, actual 0
*/
```
17 changes: 15 additions & 2 deletions packages/schema/src/ParseResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
let candidates: Array<AST.AST> = []
if (len > 0) {
// if there is at least one key then input must be an object
if (Predicate.isRecord(input)) {
if (isObject(input)) {
for (let i = 0; i < len; i++) {
const name = ownKeys[i]
const buckets = searchTree.keys[name].buckets
Expand All @@ -1418,7 +1418,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
}
} else {
const literals = AST.Union.make(searchTree.keys[name].literals)
const fakeps = new AST.PropertySignature(name, literals, false, true) // TODO: inherit message annotation from the union?
const fakeps = new AST.PropertySignature(name, literals, false, true)
es.push([
stepKey++,
new Composite(
Expand Down Expand Up @@ -1520,6 +1520,8 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => {
}
}

const isObject = (input: unknown): input is { [x: PropertyKey]: unknown } => typeof input === "object" && input !== null

const fromRefinement = <A>(ast: AST.AST, refinement: (u: unknown) => u is A): Parser => (u) =>
refinement(u) ? Either.right(u) : Either.left(new Type(ast, u))

Expand Down Expand Up @@ -1547,6 +1549,17 @@ export const getLiterals = (
}
return out
}
case "TupleType": {
const out: Array<[PropertyKey, AST.Literal]> = []
for (let i = 0; i < ast.elements.length; i++) {
const element = ast.elements[i]
const type = isDecoding ? AST.encodedAST(element.type) : AST.typeAST(element.type)
if (AST.isLiteral(type) && !element.isOptional) {
out.push([i, type])
}
}
return out
}
case "Refinement":
return getLiterals(ast.from, isDecoding)
case "Suspend":
Expand Down
98 changes: 97 additions & 1 deletion packages/schema/test/ParseResult.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,16 @@ describe("ParseIssue.actual", () => {
expect(P.getLiterals(S.String.ast, true)).toEqual([])
})

it("TypeLiteral", () => {
it("Struct", () => {
expect(P.getLiterals(S.Struct({ _tag: S.Literal("a") }).ast, true))
.toEqual([["_tag", new AST.Literal("a")]])
})

it("Tuple", () => {
expect(P.getLiterals(S.Tuple(S.Literal("a"), S.String).ast, true))
.toEqual([[0, new AST.Literal("a")]])
})

it("Refinement", () => {
expect(
P.getLiterals(
Expand Down Expand Up @@ -369,6 +374,97 @@ describe("ParseIssue.actual", () => {
})
})

it("struct + struct (multiple tags)", () => {
const A = S.Struct({ _tag: S.Literal("A"), _tag2: S.Literal("A1"), c: S.String })
const B = S.Struct({ _tag: S.Literal("A"), _tag2: S.Literal("A2"), d: S.Number })
expect(
P.getSearchTree([A.ast, B.ast], true)
).toEqual({
keys: {
_tag: {
buckets: {
A: [A.ast]
},
literals: [new AST.Literal("A")]
},
_tag2: {
buckets: {
A2: [B.ast]
},
literals: [new AST.Literal("A2")]
}
},
otherwise: []
})
})

it("tuple + tuple (same tag key)", () => {
const a = S.Tuple(S.Literal("a"), S.String)
const b = S.Tuple(S.Literal("b"), S.Number)
expect(
P.getSearchTree([a.ast, b.ast], true)
).toEqual({
keys: {
0: {
buckets: {
a: [a.ast],
b: [b.ast]
},
literals: [new AST.Literal("a"), new AST.Literal("b")]
}
},
otherwise: []
})
})

it("tuple + tuple (different tag key)", () => {
const a = S.Tuple(S.Literal("a"), S.String)
const b = S.Tuple(S.Number, S.Literal("b"))
expect(
P.getSearchTree([a.ast, b.ast], true)
).toEqual({
keys: {
0: {
buckets: {
a: [a.ast]
},
literals: [new AST.Literal("a")]
},
1: {
buckets: {
b: [b.ast]
},
literals: [new AST.Literal("b")]
}
},
otherwise: []
})
})

it("tuple + tuple (multiple tags)", () => {
const a = S.Tuple(S.Literal("a"), S.Literal("b"), S.String)
const b = S.Tuple(S.Literal("a"), S.Literal("c"), S.Number)
expect(
P.getSearchTree([a.ast, b.ast], true)
).toEqual({
keys: {
0: {
buckets: {
a: [a.ast]
},
literals: [new AST.Literal("a")]
},
1: {
buckets: {
c: [b.ast]
},
literals: [new AST.Literal("c")]
}
},
otherwise: []
})
})

it("should handle multiple tags", () => {
const a = S.Struct({ category: S.Literal("catA"), tag: S.Literal("a") })
const b = S.Struct({ category: S.Literal("catA"), tag: S.Literal("b") })
Expand Down
97 changes: 95 additions & 2 deletions packages/schema/test/Schema/Union/Union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("Union", () => {
await Util.expectDecodeUnknownFailure(schema, 1, "Expected never, actual 1")
})

it("members with literals but the input doesn't have any", async () => {
it("struct members", async () => {
const schema = S.Union(
S.Struct({ a: S.Literal(1), c: S.String }),
S.Struct({ b: S.Literal(2), d: S.Number })
Expand Down Expand Up @@ -82,7 +82,7 @@ describe("Union", () => {
)
})

it("members with multiple tags", async () => {
it("struct members with multiple tags", async () => {
const schema = S.Union(
S.Struct({ category: S.Literal("catA"), tag: S.Literal("a") }),
S.Struct({ category: S.Literal("catA"), tag: S.Literal("b") }),
Expand Down Expand Up @@ -157,6 +157,99 @@ describe("Union", () => {
└─ is missing`
)
})

it("tuple members", async () => {
const schema = S.Union(
S.Tuple(S.Literal("a"), S.String),
S.Tuple(S.Literal("b"), S.Number)
).annotations({ identifier: "MyUnion" })

await Util.expectDecodeUnknownSuccess(schema, ["a", "s"])
await Util.expectDecodeUnknownSuccess(schema, ["b", 1])

await Util.expectDecodeUnknownFailure(schema, null, `Expected MyUnion, actual null`)
await Util.expectDecodeUnknownFailure(
schema,
[],
`MyUnion
└─ { readonly 0: "a" | "b" }
└─ ["0"]
└─ is missing`
)
await Util.expectDecodeUnknownFailure(
schema,
["c"],
`MyUnion
└─ { readonly 0: "a" | "b" }
└─ ["0"]
└─ Expected "a" | "b", actual "c"`
)
await Util.expectDecodeUnknownFailure(
schema,
["a", 0],
`MyUnion
└─ readonly ["a", string]
└─ [1]
└─ Expected string, actual 0`
)
})

it("tuple members with multiple tags", async () => {
const schema = S.Union(
S.Tuple(S.Literal("a"), S.Literal("b"), S.String),
S.Tuple(S.Literal("a"), S.Literal("c"), S.Number),
S.Tuple(S.Literal("a"), S.Literal("d"), S.Boolean)
).annotations({ identifier: "MyUnion" })

await Util.expectDecodeUnknownSuccess(schema, ["a", "b", "s"])
await Util.expectDecodeUnknownSuccess(schema, ["a", "c", 1])

await Util.expectDecodeUnknownFailure(schema, null, `Expected MyUnion, actual null`)
await Util.expectDecodeUnknownFailure(
schema,
[],
`MyUnion
├─ { readonly 0: "a" }
│ └─ ["0"]
│ └─ is missing
└─ { readonly 1: "c" | "d" }
└─ ["1"]
└─ is missing`
)
await Util.expectDecodeUnknownFailure(
schema,
["c"],
`MyUnion
├─ { readonly 0: "a" }
│ └─ ["0"]
│ └─ Expected "a", actual "c"
└─ { readonly 1: "c" | "d" }
└─ ["1"]
└─ is missing`
)
await Util.expectDecodeUnknownFailure(
schema,
["a", "c"],
`MyUnion
├─ readonly ["a", "b", string]
│ └─ [2]
│ └─ is missing
└─ readonly ["a", "c", number]
└─ [2]
└─ is missing`
)
await Util.expectDecodeUnknownFailure(
schema,
["a", "b", 0],
`MyUnion
├─ { readonly 1: "c" | "d" }
│ └─ ["1"]
│ └─ Expected "c" | "d", actual "b"
└─ readonly ["a", "b", string]
└─ [2]
└─ Expected string, actual 0`
)
})
})

describe("encoding", () => {
Expand Down

0 comments on commit f02b354

Please sign in to comment.