1import { describe, it } from "@effect/vitest"
2import {
3 Context,
4 Effect,
5 Inspectable,
6 Option,
7 Redactable,
8 Redacted,
9 Result,
10 Schema,
11 SchemaGetter,
12 SchemaIssue,
13 SchemaParser
14} from "effect"
15import { format, formatJson } from "effect/Formatter"
16import { assertFalse, assertTrue, deepStrictEqual, strictEqual } from "./utils/assert.ts"
17
18class SensitiveData implements Redactable.Redactable {
19 constructor(private secret: string) {}
20
21 f(s: string) {
22 this.secret += s
23 }
24
25 toString() {
26 return this.secret
27 }
28
29 toJSON() {
30 return { secret: this.secret }
31 }
32
33 [Redactable.symbolRedactable]() {
34 return { secret: "[REDACTED]" }
35 }
36}
37
38const data = new SensitiveData("my-secret-key")
39
40describe("Formatter", () => {
41 describe("format", () => {
42 it("null", () => {
43 strictEqual(format(null), `null`)
44 })
45
46 it("undefined", () => {
47 strictEqual(format(undefined), `undefined`)
48 })
49
50 it("string", () => {
51 strictEqual(format("a"), `"a"`)
52 })
53
54 it("number", () => {
55 strictEqual(format(123), `123`)
56 })
57
58 it("boolean", () => {
59 strictEqual(format(true), `true`)
60 })
61
62 it("symbol", () => {
63 strictEqual(format(Symbol("a")), `Symbol(a)`)
64 })
65
66 it("bigint", () => {
67 strictEqual(format(BigInt(123)), `123n`)
68 })
69
70 it("custom toString method", () => {
71 strictEqual(format({ toString: () => "custom" }), `custom`)
72 })
73
74 it("handles throwing property getters", () => {
75 const value = Object.defineProperty({ safe: 1 }, "unsafe", {
76 enumerable: true,
77 get() {
78 throw new Error("getter defect")
79 }
80 })
81
82 strictEqual(format(value), `{"safe":1,"unsafe":"[property access threw]"}`)
83 })
84
85 it("handles hostile Proxies", () => {
86 const { proxy, revoke } = Proxy.revocable({}, {})
87 revoke()
88
89 strictEqual(format(proxy), `[inspection threw]`)
90 })
91
92 it("does not expose a value when redaction throws", () => {
93 const value = {
94 toString: () => "secret",
95 [Redactable.symbolRedactable]() {
96 throw new Error("redaction defect")
97 }
98 }
99
100 strictEqual(format(value), `[inspection threw]`)
101 })
102
103 it("array", () => {
104 strictEqual(format([1, 2, 3n]), `[1,2,3n]`)
105 })
106
107 it("circular array", () => {
108 const arr: any = [1]
109 arr.push(arr)
110 strictEqual(format(arr), `[1,[Circular]]`)
111 })
112
113 it("circular Map contents", () => {
114 const obj: any = { a: 1 }
115 const map = new Map([["obj", obj]])
116 obj.map = map
117 strictEqual(format(map), `Map([["obj",{"a":1,"map":[Circular]}]])`)
118 })
119
120 it("circular Set contents", () => {
121 const obj: any = { a: 1 }
122 const set = new Set([obj])
123 obj.set = set
124 strictEqual(format(set), `Set([{"a":1,"set":[Circular]}])`)
125 })
126
127 it("object", () => {
128 strictEqual(format({ a: 1 }), `{"a":1}`)
129 strictEqual(format({ a: 1, b: 2 }), `{"a":1,"b":2}`)
130 strictEqual(format({ [Symbol.for("a")]: 1 }), `{Symbol(a):1}`)
131 strictEqual(format({ a: 1, b: [1, 2, 3n] }), `{"a":1,"b":[1,2,3n]}`)
132 })
133
134 it("circular object", () => {
135 const obj: any = { a: 1 }
136 obj.b = obj
137 strictEqual(format(obj), `{"a":1,"b":[Circular]}`)
138 })
139
140 it("preserves repeated non-circular references", () => {
141 const shared = { value: 1 }
142 strictEqual(format({ first: shared, second: shared }), `{"first":{"value":1},"second":{"value":1}}`)
143 })
144
145 it("object with null prototype", () => {
146 strictEqual(format(Object.create(null)), `{}`)
147 strictEqual(format(Object.create(null, { a: { value: 1 } })), `{"a":1}`)
148 })
149
150 it("function", () => {
151 strictEqual(
152 format(() => {}),
153 `() => {}`
154 )
155 strictEqual(
156 format(() => {
157 return 1
158 }),
159 `() => {\n\t\t\t\treturn 1;\n\t\t\t}`
160 )
161 })
162
163 it("Error", () => {
164 strictEqual(format(new Error("a")), `Error: a`)
165 strictEqual(format(new Error("a", { cause: "b" })), `Error: a (cause: "b")`)
166 strictEqual(format(new Error("a", { cause: 0 })), `Error: a (cause: 0)`)
167 strictEqual(format(new Error("a", { cause: false })), `Error: a (cause: false)`)
168 strictEqual(format(new Error("a", { cause: "" })), `Error: a (cause: "")`)
169 strictEqual(format(new Error("a", { cause: null })), `Error: a (cause: null)`)
170 strictEqual(format(new Error("a", { cause: 0n })), `Error: a (cause: 0n)`)
171 strictEqual(format(new Error("a", { cause: Number.NaN })), `Error: a (cause: NaN)`)
172 strictEqual(format(new Error("a", { cause: undefined })), `Error: a`)
173 })
174
175 it("Date", () => {
176 strictEqual(format(new Date(0)), `1970-01-01T00:00:00.000Z`)
177 strictEqual(format(new Date("invalid")), `Invalid Date`)
178 })
179
180 it("RegExp", () => {
181 strictEqual(format(/a/), `/a/`)
182 })
183
184 it("Set", () => {
185 strictEqual(format(new Set([1, 2, 3])), `Set([1,2,3])`)
186 })
187
188 it("Map", () => {
189 strictEqual(format(new Map([["a", 1], ["b", 2]])), `Map([["a",1],["b",2]])`)
190 })
191
192 it("FormData", () => {
193 const formData = new FormData()
194 formData.append("a", "1")
195 strictEqual(format(formData), `FormData([["a","1"]])`)
196 })
197
198 it("Uint8Array", () => {
199 const uint8Array = new Uint8Array([1, 2, 3])
200 strictEqual(format(uint8Array), `Uint8Array([1,2,3])`)
201 })
202
203 it("Redacted", () => {
204 strictEqual(format(Redacted.make("a")), `<redacted>`)
205 })
206
207 it("Option", () => {
208 strictEqual(
209 format(Option.some(1)),
210 `some(1)`
211 )
212 strictEqual(
213 format(Option.none()),
214 `none()`
215 )
216 })
217
218 it("Schema.Class", () => {
219 class A extends Schema.Class<A>("A")({
220 a: Schema.String
221 }) {}
222 strictEqual(format(new A({ a: "a" })), `A({"a":"a"})`)
223 })
224
225 it("Schema.Error", () => {
226 class E extends Schema.Error<E>("E")({
227 a: Schema.String
228 }) {}
229 strictEqual(format(new E({ a: "a" })), `E`)
230 })
231
232 it("Context.Service", () => {
233 const MyService = Context.Service<{ readonly value: number }>("MyService")
234 strictEqual(format(MyService).includes(`"key": "MyService"`), true)
235 })
236
237 describe("whitespace", () => {
238 it("object", () => {
239 strictEqual(format({ a: 1 }, { space: 2 }), `{"a":1}`)
240 strictEqual(
241 format({ a: 1, b: 2 }, { space: 2 }),
242 `{
243 "a": 1,
244 "b": 2
245}`
246 )
247 strictEqual(
248 format({ a: 1, b: [1, 2, 3n] }, { space: 2 }),
249 `{
250 "a": 1,
251 "b": [
252 1,
253 2,
254 3n
255 ]
256}`
257 )
258 strictEqual(format({ [Symbol.for("a")]: 1 }, { space: 2 }), `{Symbol(a):1}`)
259 })
260
261 it("circular object", () => {
262 const obj: any = { a: 1 }
263 obj.b = obj
264 strictEqual(
265 format(obj, { space: 2 }),
266 `{
267 "a": 1,
268 "b": [Circular]
269}`
270 )
271 })
272
273 it("object with null prototype", () => {
274 strictEqual(format(Object.create(null), { space: 2 }), `{}`)
275 strictEqual(
276 format(Object.create(null, { a: { value: 1 } }), { space: 2 }),
277 `{"a":1}`
278 )
279 })
280 })
281
282 it("should redact sensitive data", () => {
283 strictEqual(format(data), `{"secret":"[REDACTED]"}`)
284 strictEqual(format({ a: data }), `{"a":{"secret":"[REDACTED]"}}`)
285 })
286
287 it("redacts before specialized representations", () => {
288 const array = Object.assign(["secret"], {
289 [Redactable.symbolRedactable]: () => ["[REDACTED]"]
290 })
291 const date = Object.assign(new Date(0), {
292 [Redactable.symbolRedactable]: () => "[REDACTED]"
293 })
294
295 strictEqual(format(array), `["[REDACTED]"]`)
296 strictEqual(format(date), `"[REDACTED]"`)
297 })
298
299 it("preserves formatting options for redacted representations", () => {
300 let toStringCalls = 0
301 const value = {
302 [Redactable.symbolRedactable]: () => ({
303 a: 1,
304 b: 2,
305 toString() {
306 toStringCalls++
307 return "custom"
308 }
309 })
310 }
311
312 assertTrue(format(value, { space: 2, ignoreToString: true }).startsWith(`{\n "a": 1,\n "b": 2,`))
313 strictEqual(toStringCalls, 0)
314 })
315
316 it("tracks circular references through redacted representations", () => {
317 const value: Redactable.Redactable = {
318 [Redactable.symbolRedactable]: () => ({ value })
319 }
320
321 strictEqual(format(value), `{"value":[Circular]}`)
322 })
323 })
324
325 describe("formatJson", () => {
326 it("returns valid JSON for undefined input", () => {
327 strictEqual(formatJson(undefined), `null`)
328 })
329
330 it("should omit circular references", () => {
331 const obj: any = { a: 1 }
332 obj.self = obj
333 strictEqual(formatJson(obj), `{"a":1}`)
334 })
335
336 it("preserves repeated non-circular references", () => {
337 const shared = { a: 1 }
338 strictEqual(formatJson({ left: shared, right: shared }), `{"left":{"a":1},"right":{"a":1}}`)
339 })
340
341 it("should stringify BigInt values", () => {
342 strictEqual(formatJson(123n), `"123n"`)
343 strictEqual(formatJson({ value: 123n }), `{"value":"123n"}`)
344 strictEqual(formatJson([1n, 2n]), `["1n","2n"]`)
345 })
346
347 it("should serialize Error objects with name, message, and enumerable fields", () => {
348 strictEqual(formatJson(new Error("boom")), `{"name":"Error","message":"boom"}`)
349 strictEqual(formatJson({ error: new Error("boom") }), `{"error":{"name":"Error","message":"boom"}}`)
350
351 const nodeErr = Object.assign(new Error("ENOENT: no such file or directory"), {
352 errno: -2,
353 code: "ENOENT",
354 syscall: "open",
355 path: "/tmp/foo"
356 })
357 strictEqual(
358 formatJson(nodeErr),
359 `{"errno":-2,"code":"ENOENT","syscall":"open","path":"/tmp/foo","name":"Error","message":"ENOENT: no such file or directory"}`
360 )
361
362 const circularErr = new Error("boom")
363 ;(circularErr as any).self = circularErr
364 strictEqual(formatJson(circularErr), `{"name":"Error","message":"boom"}`)
365 })
366
367 it("should keep structured serialization for Errors that define toJSON", () => {
368 class Tagged extends Error {
369 toJSON() {
370 return { _tag: "Tagged", message: "boom" }
371 }
372 }
373 strictEqual(formatJson(new Tagged("boom")), `{"_tag":"Tagged","message":"boom"}`)
374 })
375
376 it("should redact sensitive data", () => {
377 const date = Object.assign(new Date(0), {
378 [Redactable.symbolRedactable]: () => "[REDACTED]"
379 })
380
381 strictEqual(formatJson(data), `{"secret":"[REDACTED]"}`)
382 strictEqual(formatJson({ a: data }), `{"a":{"secret":"[REDACTED]"}}`)
383 strictEqual(formatJson([data]), `[{"secret":"[REDACTED]"}]`)
384 strictEqual(formatJson(date), `"[REDACTED]"`)
385 })
386 })
387
388 describe("Inspectable.toJson", () => {
389 it("redacts before toJSON", () => {
390 deepStrictEqual(
391 Inspectable.toJson(data),
392 { secret: "[REDACTED]" }
393 )
394 })
395
396 it("preserves plain objects as structured values", () => {
397 const value: any = { count: 1n }
398 value.self = value
399
400 strictEqual(Inspectable.toJson(value), value)
401 })
402 })
403
404 describe("Inspectable.toStringUnknown", () => {
405 it("should stringify BigInt values", () => {
406 strictEqual(Inspectable.toStringUnknown(123n), `123n`)
407 strictEqual(
408 Inspectable.toStringUnknown({ value: 123n }),
409 `{
410 "value": "123n"
411}`
412 )
413 })
414 })
415
416 describe("SchemaIssue reportInput", () => {
417 const formatIssue = SchemaIssue.makeFormatterDefault()
418
419 it("supports every value-bearing issue constructor", () => {
420 const input = { value: "secret" }
421 const options = { reportInput: true } as const
422 const invalidValue = new SchemaIssue.InvalidValue()
423 const union = Schema.Union([Schema.String, Schema.Number]).ast
424 const issues: ReadonlyArray<SchemaIssue.Issue> = [
425 new SchemaIssue.InvalidType(Schema.String.ast, input, options),
426 new SchemaIssue.InvalidValue(undefined, input, options),
427 new SchemaIssue.UnexpectedKey(Schema.String.ast, input, options),
428 new SchemaIssue.Forbidden(undefined, input, options),
429 new SchemaIssue.OneOf(union, [Schema.String.ast], input, options),
430 new SchemaIssue.Filter(Schema.isMinLength(1), invalidValue, input, options),
431 new SchemaIssue.Encoding(Schema.String.ast, invalidValue, input, options),
432 new SchemaIssue.Composite(Schema.String.ast, [invalidValue], input, options),
433 new SchemaIssue.AnyOf(union, [invalidValue], input, options)
434 ]
435
436 for (const issue of issues) {
437 assertTrue(SchemaIssue.hasInput(issue))
438 strictEqual(issue.input, input)
439 assertTrue(Object.keys(issue).includes("input"))
440 }
441
442 assertFalse(SchemaIssue.hasInput(new SchemaIssue.InvalidType(Schema.String.ast)))
443 assertFalse(SchemaIssue.hasInput(new SchemaIssue.Pointer([], invalidValue)))
444 assertFalse(SchemaIssue.hasInput(new SchemaIssue.MissingKey(undefined)))
445 })
446
447 it("reports input only when enabled", () => {
448 const input = { secret: "value" }
449 const disabled = SchemaParser.decodeUnknownResult(Schema.String)(input)
450
451 assertTrue(Result.isFailure(disabled))
452 assertTrue(disabled.failure._tag === "InvalidType")
453 assertFalse(SchemaIssue.hasInput(disabled.failure))
454 strictEqual(formatIssue(disabled.failure), "Expected string")
455
456 const enabled = SchemaParser.decodeUnknownResult(Schema.String)(input, { reportInput: true })
457 assertTrue(Result.isFailure(enabled))
458 assertTrue(enabled.failure._tag === "InvalidType")
459 assertTrue(SchemaIssue.hasInput(enabled.failure))
460 strictEqual(enabled.failure.input, input)
461 assertTrue(Object.keys(enabled.failure).includes("input"))
462 assertTrue(JSON.stringify(enabled.failure).includes("secret"))
463 deepStrictEqual(Object.getOwnPropertyDescriptor(enabled.failure, "input"), {
464 value: input,
465 enumerable: true,
466 writable: true,
467 configurable: true
468 })
469 strictEqual(formatIssue(enabled.failure), `Expected string, got {"secret":"value"}`)
470 })
471
472 it("distinguishes undefined input from missing input", () => {
473 const undefinedResult = SchemaParser.decodeUnknownResult(Schema.String)(undefined, { reportInput: true })
474 assertTrue(Result.isFailure(undefinedResult))
475 assertTrue(SchemaIssue.hasInput(undefinedResult.failure))
476 strictEqual(undefinedResult.failure.input, undefined)
477 strictEqual(formatIssue(undefinedResult.failure), "Expected string, got undefined")
478
479 const missingResult = SchemaParser.decodeUnknownResult(Schema.Struct({ value: Schema.String }))(
480 {},
481 { reportInput: true }
482 )
483 assertTrue(Result.isFailure(missingResult))
484 assertTrue(missingResult.failure._tag === "Composite")
485 const pointer = missingResult.failure.issues[0]
486 assertTrue(pointer._tag === "Pointer")
487 assertFalse(SchemaIssue.hasInput(pointer))
488 assertTrue(pointer.issue._tag === "MissingKey")
489 assertFalse(SchemaIssue.hasInput(pointer.issue))
490 strictEqual(formatIssue(pointer.issue), "Missing key")
491 })
492
493 it("reports local inputs for structs", () => {
494 const input = { value: "not a number", extra: "secret" }
495 const result = SchemaParser.decodeUnknownResult(Schema.Struct({ value: Schema.Number }))(input, {
496 errors: "all",
497 onExcessProperty: "error",
498 reportInput: true
499 })
500
501 assertTrue(Result.isFailure(result))
502 assertTrue(result.failure._tag === "Composite")
503 strictEqual(result.failure.input, input)
504
505 const extraPointer = result.failure.issues[0]
506 assertTrue(extraPointer._tag === "Pointer")
507 assertFalse(SchemaIssue.hasInput(extraPointer))
508 assertTrue(extraPointer.issue._tag === "UnexpectedKey")
509 strictEqual(extraPointer.issue.input, "secret")
510
511 const valuePointer = result.failure.issues[1]
512 assertTrue(valuePointer._tag === "Pointer")
513 assertFalse(SchemaIssue.hasInput(valuePointer))
514 assertTrue(valuePointer.issue._tag === "InvalidType")
515 strictEqual(valuePointer.issue.input, "not a number")
516 })
517
518 it("reports filter input on parser-created issues", () => {
519 const result = SchemaParser.decodeUnknownResult(Schema.String.check(Schema.isMinLength(1)))("", {
520 reportInput: true
521 })
522
523 assertTrue(Result.isFailure(result))
524 assertTrue(result.failure._tag === "Composite")
525 const filter = result.failure.issues[0]
526 assertTrue(filter._tag === "Filter")
527 strictEqual(filter.input, "")
528 assertTrue(filter.issue._tag === "InvalidValue")
529 strictEqual(filter.issue.input, "")
530 strictEqual(formatIssue(filter), `Expected a value with a length of at least 1, got ""`)
531 })
532
533 it("allows user-provided transformations to report their input", () => {
534 const schema = Schema.String.pipe(
535 Schema.decode({
536 decode: SchemaGetter.transformEffect((input, options) =>
537 Effect.fail(new SchemaIssue.InvalidValue(undefined, input, options))
538 ),
539 encode: SchemaGetter.passthrough()
540 })
541 )
542 const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true })
543
544 assertTrue(Result.isFailure(result))
545 assertTrue(result.failure._tag === "Encoding")
546 assertTrue(result.failure.issue._tag === "InvalidValue")
547 strictEqual(result.failure.issue.input, "secret")
548 strictEqual(formatIssue(result.failure), `Invalid data "secret"`)
549 })
550
551 it("reports input from effectful checks", () => {
552 const schema = Schema.String.pipe(
553 Schema.decode({
554 decode: SchemaGetter.checkEffect(() => Effect.succeed(false)),
555 encode: SchemaGetter.passthrough()
556 })
557 )
558 const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true })
559
560 assertTrue(Result.isFailure(result))
561 assertTrue(result.failure._tag === "Encoding")
562 assertTrue(result.failure.issue._tag === "InvalidValue")
563 strictEqual(result.failure.issue.input, "secret")
564 strictEqual(formatIssue(result.failure), `Invalid data "secret"`)
565 })
566
567 it("does not infer input through a user-provided pointer", () => {
568 const inner = new SchemaIssue.InvalidValue()
569 const pointer = new SchemaIssue.Pointer(["value"], inner)
570 const schema = Schema.String.check(Schema.makeFilter(() => pointer))
571 const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true })
572
573 assertTrue(Result.isFailure(result))
574 assertTrue(result.failure._tag === "Composite")
575 const filter = result.failure.issues[0]
576 assertTrue(filter._tag === "Filter")
577 strictEqual(filter.input, "secret")
578 strictEqual(filter.issue, pointer)
579 assertFalse(SchemaIssue.hasInput(pointer))
580 assertFalse(SchemaIssue.hasInput(inner))
581 })
582
583 it("reports input for union wrappers", () => {
584 const anyOf = SchemaParser.decodeUnknownResult(Schema.Union([Schema.Literal("a"), Schema.Literal("b")]))(
585 "c",
586 { reportInput: true }
587 )
588 assertTrue(Result.isFailure(anyOf))
589 assertTrue(anyOf.failure._tag === "AnyOf")
590 strictEqual(anyOf.failure.input, "c")
591 strictEqual(formatIssue(anyOf.failure), `Expected "a" | "b", got "c"`)
592
593 const oneOf = SchemaParser.decodeUnknownResult(
594 Schema.Union([Schema.Literal("a"), Schema.Literal("a")], { mode: "oneOf" })
595 )("a", { reportInput: true })
596 assertTrue(Result.isFailure(oneOf))
597 assertTrue(oneOf.failure._tag === "OneOf")
598 strictEqual(oneOf.failure.input, "a")
599 strictEqual(formatIssue(oneOf.failure), `Expected exactly one member to match the input "a"`)
600 })
601
602 it("runtime reportInput overrides preset options and reaches nested schemas", () => {
603 const enabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: false })(1, {
604 reportInput: true
605 })
606 assertTrue(Result.isFailure(enabledResult))
607 strictEqual(enabledResult.failure.input, 1)
608
609 const disabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: true })(1, {
610 reportInput: false
611 })
612 assertTrue(Result.isFailure(disabledResult))
613 assertFalse(SchemaIssue.hasInput(disabledResult.failure))
614
615 const nested = Schema.Struct({ value: Schema.String })
616 const nestedInput = { value: 1 }
617 const nestedResult = SchemaParser.decodeUnknownResult(nested)(nestedInput, { reportInput: true })
618 assertTrue(Result.isFailure(nestedResult))
619 assertTrue(nestedResult.failure._tag === "Composite")
620 strictEqual(nestedResult.failure.input, nestedInput)
621 const pointer = nestedResult.failure.issues[0]
622 assertTrue(pointer._tag === "Pointer")
623 assertTrue(SchemaIssue.hasInput(pointer.issue))
624 strictEqual(pointer.issue.input, 1)
625 })
626
627 it.effect("distinguishes present undefined from absent input in forbidden", () =>
628 Effect.gen(function*() {
629 const getter = SchemaGetter.forbidden<never, undefined>(() => "not allowed")
630 assertTrue(getter._tag === "TransformOptionalEffect")
631 const present = yield* getter.transform(Option.some(undefined), { reportInput: true }).pipe(Effect.flip)
632 assertTrue(present._tag === "Forbidden")
633 assertTrue(SchemaIssue.hasInput(present))
634 strictEqual(present.input, undefined)
635 strictEqual(formatIssue(present), "not allowed")
636
637 const absent = yield* getter.transform(Option.none(), { reportInput: true }).pipe(Effect.flip)
638 assertTrue(absent._tag === "Forbidden")
639 assertFalse(SchemaIssue.hasInput(absent))
640 }))
641
642 it("formats reported inputs with the historical templates", () => {
643 const options = { reportInput: true } as const
644 const invalidValue = new SchemaIssue.InvalidValue()
645 const union = Schema.Union([Schema.String, Schema.Number]).ast
646
647 strictEqual(formatIssue(new SchemaIssue.InvalidType(Schema.String.ast, 1, options)), "Expected string, got 1")
648 strictEqual(formatIssue(new SchemaIssue.InvalidValue(undefined, 1, options)), "Invalid data 1")
649 strictEqual(
650 formatIssue(new SchemaIssue.UnexpectedKey(Schema.String.ast, 1, options)),
651 "Unexpected key with value 1"
652 )
653 strictEqual(
654 formatIssue(new SchemaIssue.OneOf(union, [Schema.String.ast], 1, options)),
655 "Expected exactly one member to match the input 1"
656 )
657 strictEqual(formatIssue(new SchemaIssue.Forbidden(undefined, 1, options)), "Forbidden operation")
658 strictEqual(
659 formatIssue(new SchemaIssue.Filter(Schema.isMinLength(1), invalidValue, 1, options)),
660 "Expected a value with a length of at least 1, got 1"
661 )
662 strictEqual(formatIssue(new SchemaIssue.AnyOf(union, [], 1, options)), "Expected string | number, got 1")
663 })
664
665 it("does not inherit input from wrappers", () => {
666 const options = { reportInput: true } as const
667 const inner = new SchemaIssue.InvalidValue()
668
669 strictEqual(
670 formatIssue(new SchemaIssue.Encoding(Schema.String.ast, inner, "secret", options)),
671 "Expected a valid value"
672 )
673 strictEqual(
674 formatIssue(new SchemaIssue.Composite(Schema.String.ast, [inner], "secret", options)),
675 "Expected a valid value"
676 )
677 strictEqual(
678 formatIssue(
679 new SchemaIssue.Filter(
680 Schema.isMinLength(1),
681 new SchemaIssue.InvalidValue(undefined, "secret", options)
682 )
683 ),
684 "Expected a value with a length of at least 1"
685 )
686 })
687
688 it("uses expected annotations only for InvalidValue", () => {
689 strictEqual(formatIssue(new SchemaIssue.InvalidValue({ expected: "a valid value" })), "Expected a valid value")
690 strictEqual(
691 formatIssue(new SchemaIssue.InvalidValue({ expected: "a valid value" }, "secret", { reportInput: true })),
692 `Expected a valid value, got "secret"`
693 )
694 strictEqual(
695 formatIssue(
696 new SchemaIssue.InvalidValue(
697 { expected: "ignored", message: "custom message" },
698 "secret",
699 { reportInput: true }
700 )
701 ),
702 "custom message"
703 )
704 strictEqual(
705 formatIssue(new SchemaIssue.Forbidden({ expected: "a permitted operation" }, "secret", { reportInput: true })),
706 "Forbidden operation"
707 )
708 const filter = new SchemaIssue.Filter(
709 Schema.isMinLength(1),
710 new SchemaIssue.InvalidValue({ expected: "a custom value" }, "secret", { reportInput: true })
711 )
712 strictEqual(formatIssue(filter), `Expected a custom value, got "secret"`)
713 deepStrictEqual(
714 SchemaIssue.makeFormatterStandardSchemaV1({ checkHook: () => undefined })(filter).issues,
715 [{ message: `Expected a custom value, got "secret"`, path: [] }]
716 )
717 })
718
719 it("uses expected annotations in built-ins", () => {
720 const disabled = SchemaParser.decodeUnknownResult(Schema.URLFromString)("invalid")
721 assertTrue(Result.isFailure(disabled))
722 strictEqual(formatIssue(disabled.failure), "Expected a valid URL string")
723
724 const enabled = SchemaParser.decodeUnknownResult(Schema.URLFromString)("invalid", { reportInput: true })
725 assertTrue(Result.isFailure(enabled))
726 strictEqual(formatIssue(enabled.failure), `Expected a valid URL string, got "invalid"`)
727 })
728
729 it("keeps Redacted input redacted", () => {
730 const input = Redacted.make("secret", { label: "password" })
731 const result = SchemaParser.decodeUnknownResult(Schema.Redacted(Schema.Number))(input, { reportInput: true })
732
733 assertTrue(Result.isFailure(result))
734 assertTrue(result.failure._tag === "Composite")
735 const pointer = result.failure.issues[0]
736 assertTrue(pointer._tag === "Pointer")
737 assertTrue(pointer.issue._tag === "InvalidValue")
738 strictEqual(pointer.issue.input, input)
739 strictEqual(formatIssue(result.failure), "Invalid data <redacted:password>\n at [\"value\"]")
740 })
741
742 it("preserves custom messages and hooks", () => {
743 const options = { reportInput: true } as const
744 const invalidType = new SchemaIssue.InvalidType(
745 Schema.String.annotate({ message: "custom message" }).ast,
746 "secret",
747 options
748 )
749 strictEqual(formatIssue(invalidType), "custom message")
750
751 const leafFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: () => "redacted leaf" })
752 deepStrictEqual(leafFormatter(invalidType).issues, [{ message: "redacted leaf", path: [] }])
753
754 const filter = new SchemaIssue.Filter(Schema.isMinLength(1), new SchemaIssue.InvalidValue(), "secret", options)
755 const checkFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ checkHook: () => "redacted check" })
756 deepStrictEqual(checkFormatter(filter).issues, [{ message: "redacted check", path: [] }])
757 })
758
759 it("reports input in Standard Schema messages without adding an input field", () => {
760 const standardSchema = Schema.toStandardSchemaV1(Schema.String, {
761 parseOptions: { reportInput: true }
762 })
763 const result = standardSchema["~standard"].validate(1)
764 if (result instanceof Promise) {
765 throw new Error("Expected synchronous validation")
766 }
767 deepStrictEqual(result, {
768 issues: [{ message: "Expected string, got 1", path: [] }]
769 })
770 })
771 })
772})