| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { |
| 3 | Cause, |
| 4 | Deferred, |
| 5 | Effect, |
| 6 | Fiber, |
| 7 | Result, |
| 8 | Schema, |
| 9 | SchemaGetter, |
| 10 | SchemaIssue, |
| 11 | SchemaParser, |
| 12 | SchemaTransformation |
| 13 | } from "effect" |
| 14 | import { SchemaCompiler } from "effect/schema" |
| 15 | // oxlint-disable-next-line no-unassigned-import |
| 16 | import "effect/schema/SchemaJITCompiler/enable" |
| 17 | import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" |
| 18 | |
| 19 | const schema = Schema.Struct({ |
| 20 | name: Schema.String, |
| 21 | count: Schema.Number, |
| 22 | active: Schema.Boolean, |
| 23 | nested: Schema.Struct({ value: Schema.String }) |
| 24 | }) |
| 25 | |
| 26 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 27 | const is = SchemaParser.is(schema) |
| 28 | |
| 29 | describe("SchemaJITCompiler", () => { |
| 30 | it.effect("preserves product concurrency", () => |
| 31 | Effect.gen(function*() { |
| 32 | const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make<void>()) |
| 33 | const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make<void>()) |
| 34 | const item = Schema.String.pipe(Schema.decode({ |
| 35 | decode: SchemaGetter.transformEffect((value) => { |
| 36 | const index = value.charCodeAt(0) - 97 |
| 37 | return Deferred.succeed(started[index], undefined).pipe( |
| 38 | Effect.andThen(Deferred.await(releases[index])), |
| 39 | Effect.as(value) |
| 40 | ) |
| 41 | }), |
| 42 | encode: SchemaGetter.passthrough() |
| 43 | })) |
| 44 | const schema = Schema.Struct({ a: item, b: item, c: item }) |
| 45 | const fiber = yield* SchemaParser.decodeUnknownEffect(schema)( |
| 46 | { a: "a", b: "b", c: "c" }, |
| 47 | { concurrency: 2 } |
| 48 | ).pipe(Effect.forkChild) |
| 49 | |
| 50 | yield* Deferred.await(started[0]) |
| 51 | yield* Deferred.await(started[1]) |
| 52 | strictEqual(yield* Deferred.isDone(started[2]), false) |
| 53 | |
| 54 | yield* Deferred.succeed(releases[0], undefined) |
| 55 | yield* Deferred.await(started[2]) |
| 56 | yield* Deferred.succeed(releases[1], undefined) |
| 57 | yield* Deferred.succeed(releases[2], undefined) |
| 58 | |
| 59 | deepStrictEqual(yield* Fiber.join(fiber), { a: "a", b: "b", c: "c" }) |
| 60 | })) |
| 61 | |
| 62 | it("compiles a decoder lazily after import", () => { |
| 63 | const input = { |
| 64 | name: "a", |
| 65 | count: 1, |
| 66 | active: true, |
| 67 | nested: { value: "b", extra: true }, |
| 68 | extra: true |
| 69 | } |
| 70 | const output = decode(input) |
| 71 | |
| 72 | deepStrictEqual(output, { |
| 73 | name: "a", |
| 74 | count: 1, |
| 75 | active: true, |
| 76 | nested: { value: "b" } |
| 77 | }) |
| 78 | assert.notStrictEqual(output, input) |
| 79 | assert.notStrictEqual(output.nested, input.nested) |
| 80 | }) |
| 81 | |
| 82 | it("compiles type guards", () => { |
| 83 | strictEqual( |
| 84 | is({ |
| 85 | name: "a", |
| 86 | count: 1, |
| 87 | active: true, |
| 88 | nested: { value: "b" } |
| 89 | }), |
| 90 | true |
| 91 | ) |
| 92 | strictEqual( |
| 93 | is({ |
| 94 | name: 1, |
| 95 | count: 1, |
| 96 | active: true, |
| 97 | nested: { value: "b" } |
| 98 | }), |
| 99 | false |
| 100 | ) |
| 101 | |
| 102 | const defect = new Error("boom") |
| 103 | let reads = 0 |
| 104 | throws(() => |
| 105 | is({ |
| 106 | get name(): string { |
| 107 | reads++ |
| 108 | throw defect |
| 109 | }, |
| 110 | count: 1, |
| 111 | active: true, |
| 112 | nested: { value: "b" } |
| 113 | }), (error) => { |
| 114 | assert(error instanceof Error) |
| 115 | strictEqual(error.message, "Type guard adapter can only return false for schema issues") |
| 116 | assert(Cause.hasDies(error.cause as Cause.Cause<never>)) |
| 117 | }) |
| 118 | strictEqual(reads, 1) |
| 119 | }) |
| 120 | |
| 121 | it("does not confuse a valid value with the invalid sentinel", () => { |
| 122 | const schemas = [ |
| 123 | Schema.Union([Schema.Symbol, Schema.String]), |
| 124 | Schema.Union([Schema.UniqueSymbol(SchemaCompiler.invalid), Schema.Literal("valid")]) |
| 125 | ] |
| 126 | for (const schema of schemas) { |
| 127 | strictEqual(SchemaParser.is(schema)(SchemaCompiler.invalid), true) |
| 128 | strictEqual(SchemaParser.decodeUnknownSync(schema)(SchemaCompiler.invalid), SchemaCompiler.invalid) |
| 129 | } |
| 130 | }) |
| 131 | |
| 132 | it("uses default options in compiled type guards", () => { |
| 133 | const structural = Schema.Struct({ value: Schema.String }) |
| 134 | const is = SchemaParser.is(structural) |
| 135 | strictEqual(is({ value: "a" }), true) |
| 136 | strictEqual(is({ value: "a", extra: true }), true) |
| 137 | strictEqual(is({ value: 1 }), false) |
| 138 | |
| 139 | const checked = Schema.Struct({ a: Schema.String, b: Schema.String }).check( |
| 140 | Schema.makeFilter((value, _ast, options) => options.reportInput === true && !Object.hasOwn(value, "extra")) |
| 141 | ) |
| 142 | const input = { b: "b", a: "a", extra: true } |
| 143 | strictEqual(SchemaParser.is(checked)(input), false) |
| 144 | deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { reportInput: true }), { a: "a", b: "b" }) |
| 145 | deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { disableChecks: true }), { a: "a", b: "b" }) |
| 146 | strictEqual(SchemaParser.is(checked)(input), false) |
| 147 | }) |
| 148 | |
| 149 | it("accepts the invalid sentinel inside a checked composite", () => { |
| 150 | const schema = Schema.Struct({ value: Schema.Union([Schema.Symbol, Schema.String]) }).check( |
| 151 | Schema.makeFilter(() => true) |
| 152 | ) |
| 153 | const input = { value: SchemaCompiler.invalid } |
| 154 | strictEqual(SchemaParser.is(schema)(input), true) |
| 155 | deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) |
| 156 | }) |
| 157 | |
| 158 | it("keeps runtime options for nested checks, template parts and record keys", () => { |
| 159 | const checked = Schema.Struct({ |
| 160 | nested: Schema.Struct({ value: Schema.String }).check( |
| 161 | Schema.makeFilter((_value, _ast, options) => options.reportInput === true) |
| 162 | ) |
| 163 | }) |
| 164 | const value = { nested: { value: "valid" } } |
| 165 | strictEqual(SchemaParser.is(checked)(value), false) |
| 166 | deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(value, { reportInput: true }), value) |
| 167 | |
| 168 | const template = Schema.Struct({ |
| 169 | value: Schema.TemplateLiteral(["prefix-", Schema.String.check(Schema.isMinLength(2))]) |
| 170 | }) |
| 171 | strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) |
| 172 | deepStrictEqual(SchemaParser.decodeUnknownSync(template)({ value: "prefix-a" }, { disableChecks: true }), { |
| 173 | value: "prefix-a" |
| 174 | }) |
| 175 | strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) |
| 176 | |
| 177 | const record = Schema.Record(Schema.String.check(Schema.isStartingWith("x")), Schema.Number) |
| 178 | const decode = SchemaParser.decodeUnknownSync(record) |
| 179 | deepStrictEqual(decode({ x: 1, y: 2 }), { x: 1 }) |
| 180 | deepStrictEqual(decode({ x: 1, y: 2 }, { disableChecks: true }), { x: 1, y: 2 }) |
| 181 | }) |
| 182 | |
| 183 | it("runs the diagnostic phase after fast validation fails", () => { |
| 184 | let reads = 0 |
| 185 | const input = { |
| 186 | get name() { |
| 187 | reads++ |
| 188 | return 1 |
| 189 | }, |
| 190 | count: 1, |
| 191 | active: true, |
| 192 | nested: { value: "b" } |
| 193 | } |
| 194 | |
| 195 | throws(() => decode(input), (error) => { |
| 196 | assertSchemaIssueError(error, `Expected string\n at ["name"]`) |
| 197 | }) |
| 198 | strictEqual(reads, 2) |
| 199 | }) |
| 200 | |
| 201 | it("runs one diagnostic pass for a nested failure", () => { |
| 202 | let checks = 0 |
| 203 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 204 | nested: Schema.Struct({ |
| 205 | value: Schema.String.check(Schema.makeFilter(() => { |
| 206 | checks++ |
| 207 | return false |
| 208 | })) |
| 209 | }) |
| 210 | })) |
| 211 | |
| 212 | throws(() => decode({ nested: { value: "invalid" } }), (error) => { |
| 213 | assertSchemaIssueError( |
| 214 | error, |
| 215 | `Expected <filter> |
| 216 | at ["nested"]["value"]` |
| 217 | ) |
| 218 | }) |
| 219 | strictEqual(checks, 2) |
| 220 | }) |
| 221 | |
| 222 | it("preserves diagnostics with shared parser helpers", () => { |
| 223 | const schema = Schema.Struct({ value: Schema.String }) |
| 224 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 225 | |
| 226 | deepStrictEqual(decode({ value: "a", extra: true }), { value: "a" }) |
| 227 | throws(() => decode({ value: 1 }), (error) => { |
| 228 | assertSchemaIssueError( |
| 229 | error, |
| 230 | `Expected string |
| 231 | at ["value"]` |
| 232 | ) |
| 233 | }) |
| 234 | throws(() => decode({ value: "a", extra: true }, { onExcessProperty: "error" }), (error) => { |
| 235 | assertSchemaIssueError( |
| 236 | error, |
| 237 | `Expected no excess property |
| 238 | at ["extra"]` |
| 239 | ) |
| 240 | }) |
| 241 | }) |
| 242 | |
| 243 | it("executes transformations and middleware once", () => { |
| 244 | let transformations = 0 |
| 245 | const transformed = Schema.String.pipe( |
| 246 | Schema.decodeTo( |
| 247 | Schema.String.check(Schema.isMinLength(2)), |
| 248 | SchemaTransformation.transform({ |
| 249 | decode: (value) => { |
| 250 | transformations++ |
| 251 | return value.trim() |
| 252 | }, |
| 253 | encode: (value) => value |
| 254 | }) |
| 255 | ) |
| 256 | ) |
| 257 | const decodeTransformed = SchemaParser.decodeUnknownSync(transformed) |
| 258 | |
| 259 | strictEqual(decodeTransformed(" valid "), "valid") |
| 260 | strictEqual(transformations, 1) |
| 261 | throws(() => decodeTransformed(" x ")) |
| 262 | strictEqual(transformations, 2) |
| 263 | |
| 264 | let middlewareRuns = 0 |
| 265 | const middleware = Schema.Struct({ value: Schema.String }).pipe( |
| 266 | Schema.middlewareDecoding((effect) => { |
| 267 | middlewareRuns++ |
| 268 | return effect |
| 269 | }) |
| 270 | ) |
| 271 | |
| 272 | deepStrictEqual(SchemaParser.decodeUnknownSync(middleware)({ value: "valid" }), { value: "valid" }) |
| 273 | strictEqual(middlewareRuns, 1) |
| 274 | }) |
| 275 | |
| 276 | it("decodes transformed Struct properties with runtime options", () => { |
| 277 | const schema = Schema.Struct({ |
| 278 | first: Schema.FiniteFromString, |
| 279 | second: Schema.FiniteFromString |
| 280 | }) |
| 281 | |
| 282 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 283 | for ( |
| 284 | const options of [ |
| 285 | undefined, |
| 286 | {}, |
| 287 | { errors: "first" }, |
| 288 | { onExcessProperty: "ignore" }, |
| 289 | { reportInput: true }, |
| 290 | { disableChecks: true } |
| 291 | ] as const |
| 292 | ) { |
| 293 | deepStrictEqual(decode({ first: "1", second: "2" }, options), { first: 1, second: 2 }) |
| 294 | } |
| 295 | }) |
| 296 | |
| 297 | it("does not replay inlined Struct transformations", () => { |
| 298 | let transformations = 0 |
| 299 | const transformed = Schema.String.pipe( |
| 300 | Schema.decodeTo( |
| 301 | Schema.Number, |
| 302 | SchemaTransformation.transform({ |
| 303 | decode: (value) => { |
| 304 | transformations++ |
| 305 | return value === "invalid" ? "invalid" as any : Number(value) |
| 306 | }, |
| 307 | encode: String |
| 308 | }) |
| 309 | ) |
| 310 | ) |
| 311 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ value: transformed })) |
| 312 | |
| 313 | deepStrictEqual(decode({ value: "1" }), { value: 1 }) |
| 314 | strictEqual(transformations, 1) |
| 315 | throws(() => decode({ value: false })) |
| 316 | strictEqual(transformations, 1) |
| 317 | throws(() => decode({ value: "invalid" })) |
| 318 | strictEqual(transformations, 2) |
| 319 | }) |
| 320 | |
| 321 | it("applies Struct output and encoding checks after compiled fields without replay", () => { |
| 322 | let transformations = 0 |
| 323 | let checks = 0 |
| 324 | const schema = Schema.Struct({ |
| 325 | value: Schema.String.pipe(Schema.decodeTo( |
| 326 | Schema.Number, |
| 327 | SchemaTransformation.transform({ |
| 328 | decode: (value) => { |
| 329 | transformations++ |
| 330 | return Number(value) |
| 331 | }, |
| 332 | encode: String |
| 333 | }) |
| 334 | )) |
| 335 | }).check(Schema.makeFilter((output) => { |
| 336 | checks++ |
| 337 | deepStrictEqual(Object.keys(output), ["value"]) |
| 338 | return output.value > 0 |
| 339 | })).pipe( |
| 340 | Schema.flip, |
| 341 | Schema.check(Schema.makeFilter((input) => input.value !== "01")), |
| 342 | Schema.flip |
| 343 | ) |
| 344 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 345 | deepStrictEqual(decode({ value: "1", extra: true }), { value: 1 }) |
| 346 | throws(() => decode({ value: "-1", extra: true })) |
| 347 | strictEqual(transformations, 2) |
| 348 | strictEqual(checks, 2) |
| 349 | |
| 350 | deepStrictEqual(decode({ value: "-1" }, { disableChecks: true }), { value: -1 }) |
| 351 | strictEqual(checks, 2) |
| 352 | throws(() => decode({ value: "-1" }, { errors: "all" })) |
| 353 | strictEqual(transformations, 4) |
| 354 | strictEqual(checks, 3) |
| 355 | throws(() => decode({ value: "01" })) |
| 356 | strictEqual(transformations, 5) |
| 357 | strictEqual(checks, 3) |
| 358 | }) |
| 359 | |
| 360 | it("replaces the parser used by the other decoding adapters", () => { |
| 361 | let checks = 0 |
| 362 | const schema = Schema.String.check(Schema.makeFilter(() => { |
| 363 | checks++ |
| 364 | return false |
| 365 | })) |
| 366 | |
| 367 | assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)("value"))) |
| 368 | strictEqual(checks, 2) |
| 369 | }) |
| 370 | |
| 371 | it("replaces the parser used by encoding adapters", () => { |
| 372 | let checks = 0 |
| 373 | const schema = Schema.String.check(Schema.makeFilter(() => { |
| 374 | checks++ |
| 375 | return false |
| 376 | })) |
| 377 | |
| 378 | assert(Result.isFailure(SchemaParser.encodeUnknownResult(schema)("value"))) |
| 379 | strictEqual(checks, 2) |
| 380 | }) |
| 381 | |
| 382 | it("does not replay defects", () => { |
| 383 | const defect = new Error("boom") |
| 384 | let reads = 0 |
| 385 | const input = { |
| 386 | get name(): string { |
| 387 | reads++ |
| 388 | throw defect |
| 389 | }, |
| 390 | count: 1, |
| 391 | active: true, |
| 392 | nested: { value: "b" } |
| 393 | } |
| 394 | |
| 395 | throws(() => decode(input), (error) => { |
| 396 | assert(error instanceof Error) |
| 397 | strictEqual(error.message, "Sync adapter can only throw schema issues") |
| 398 | assert(Cause.hasDies(error.cause as Cause.Cause<never>)) |
| 399 | }) |
| 400 | strictEqual(reads, 1) |
| 401 | }) |
| 402 | |
| 403 | it("honors explicit ParseOptions in the compiled diagnostic phase", () => { |
| 404 | throws(() => |
| 405 | decode({ |
| 406 | name: "a", |
| 407 | count: 1, |
| 408 | active: true, |
| 409 | nested: { value: "b", nestedExtra: true }, |
| 410 | extra: true |
| 411 | }, { onExcessProperty: "error", errors: "all" }), (error) => { |
| 412 | assertSchemaIssueError( |
| 413 | error, |
| 414 | `Expected no excess property |
| 415 | at ["extra"] |
| 416 | Expected no excess property |
| 417 | at ["nested"]["nestedExtra"]` |
| 418 | ) |
| 419 | }) |
| 420 | }) |
| 421 | |
| 422 | it("compiles symbol-keyed Struct properties", () => { |
| 423 | const key = Symbol("key") |
| 424 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 425 | text: Schema.String, |
| 426 | [key]: Schema.Number |
| 427 | })) |
| 428 | const output = decode({ text: "value", [key]: 1, extra: true }) |
| 429 | |
| 430 | strictEqual(output.text, "value") |
| 431 | strictEqual(output[key], 1) |
| 432 | deepStrictEqual(Reflect.ownKeys(output), ["text", key]) |
| 433 | }) |
| 434 | |
| 435 | it("uses the interpreter for unsupported schemas", () => { |
| 436 | const date = new Date(0) |
| 437 | const decode = SchemaParser.decodeUnknownSync(Schema.instanceOf(Date)) |
| 438 | strictEqual(decode(date), date) |
| 439 | }) |
| 440 | |
| 441 | it("uses the interpreter when dynamic function generation is unavailable", () => { |
| 442 | const Function = globalThis.Function |
| 443 | try { |
| 444 | globalThis.Function = (() => { |
| 445 | throw new Error("dynamic function generation unavailable") |
| 446 | }) as any |
| 447 | const schema = Schema.Struct({ value: Schema.String }) |
| 448 | const getParser = schema.ast.getParser.bind(schema.ast) |
| 449 | let interpreterConstructions = 0 |
| 450 | Object.defineProperty(schema.ast, "getParser", { |
| 451 | configurable: true, |
| 452 | value(...args: Parameters<typeof getParser>) { |
| 453 | interpreterConstructions++ |
| 454 | return getParser(...args) |
| 455 | } |
| 456 | }) |
| 457 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 458 | |
| 459 | deepStrictEqual(decode({ value: "a" }), { value: "a" }) |
| 460 | strictEqual(interpreterConstructions, 1) |
| 461 | throws(() => decode({ value: 1 }), (error) => { |
| 462 | assertSchemaIssueError( |
| 463 | error, |
| 464 | `Expected string |
| 465 | at ["value"]` |
| 466 | ) |
| 467 | }) |
| 468 | } finally { |
| 469 | globalThis.Function = Function |
| 470 | } |
| 471 | }) |
| 472 | |
| 473 | it("compiles primitive leaves without confusing undefined with a missing key", () => { |
| 474 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 475 | undefined: Schema.Undefined, |
| 476 | unknown: Schema.Unknown, |
| 477 | bigint: Schema.BigInt, |
| 478 | symbol: Schema.Symbol, |
| 479 | literal: Schema.Literal("a") |
| 480 | })) |
| 481 | const symbol = globalThis.Symbol("a") |
| 482 | |
| 483 | deepStrictEqual( |
| 484 | decode({ undefined, unknown: undefined, bigint: 1n, symbol, literal: "a", extra: true }), |
| 485 | { undefined, unknown: undefined, bigint: 1n, symbol, literal: "a" } |
| 486 | ) |
| 487 | throws(() => decode({ unknown: undefined, bigint: 1n, symbol, literal: "a" }), (error) => { |
| 488 | assertSchemaIssueError(error, `Missing key\n at ["undefined"]`) |
| 489 | }) |
| 490 | }) |
| 491 | |
| 492 | it("compiles arrays and tuples with rest and tail elements", () => { |
| 493 | const decodeArray = SchemaParser.decodeUnknownSync(Schema.Array(Schema.String)) |
| 494 | deepStrictEqual(decodeArray(["a", "b"]), ["a", "b"]) |
| 495 | |
| 496 | const decodeTuple = SchemaParser.decodeUnknownSync( |
| 497 | Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) |
| 498 | ) |
| 499 | deepStrictEqual(decodeTuple(["a", 1, 2, true]), ["a", 1, 2, true]) |
| 500 | throws(() => decodeTuple(["a", 1, 2]), (error) => { |
| 501 | assertSchemaIssueError(error, `Expected boolean\n at [2]`) |
| 502 | }) |
| 503 | }) |
| 504 | |
| 505 | it("compiles optional tuple elements", () => { |
| 506 | const schema = Schema.Tuple([Schema.optionalKey(Schema.String)]) |
| 507 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 508 | const is = SchemaParser.is(schema) |
| 509 | |
| 510 | deepStrictEqual(decode([]), []) |
| 511 | deepStrictEqual(decode(["a"]), ["a"]) |
| 512 | strictEqual(is([]), true) |
| 513 | strictEqual(is(["a"]), true) |
| 514 | strictEqual(is([1]), false) |
| 515 | }) |
| 516 | |
| 517 | it("preserves the input sign for signed-zero literals", () => { |
| 518 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 519 | negative: Schema.Literal(-0), |
| 520 | positive: Schema.Union([Schema.Literal(0), Schema.Literal(1)]), |
| 521 | union: Schema.Union([Schema.Literal(-0), Schema.Literal(1)]) |
| 522 | })) |
| 523 | const output = decode({ negative: 0, positive: -0, union: 0 }) |
| 524 | |
| 525 | strictEqual(Object.is(output.negative, 0), true) |
| 526 | strictEqual(Object.is(output.positive, -0), true) |
| 527 | strictEqual(Object.is(output.union, 0), true) |
| 528 | }) |
| 529 | |
| 530 | it("compiles primitive anyOf and oneOf unions", () => { |
| 531 | const decodeAnyOf = SchemaParser.decodeUnknownSync( |
| 532 | Schema.Union([Schema.Literal("a"), Schema.Literal("b"), Schema.Number]) |
| 533 | ) |
| 534 | strictEqual(decodeAnyOf("b"), "b") |
| 535 | strictEqual(decodeAnyOf(1), 1) |
| 536 | throws(() => decodeAnyOf(true), (error) => { |
| 537 | assertSchemaIssueError(error, "Expected \"a\" | \"b\" | number") |
| 538 | }) |
| 539 | |
| 540 | const decodeOneOf = SchemaParser.decodeUnknownSync( |
| 541 | Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }) |
| 542 | ) |
| 543 | strictEqual(decodeOneOf("b"), "b") |
| 544 | throws(() => decodeOneOf("a"), (error) => { |
| 545 | assertSchemaIssueError(error, "Expected exactly one member to match") |
| 546 | }) |
| 547 | }) |
| 548 | |
| 549 | it("compiles structural unions through canonical candidate selection", () => { |
| 550 | const decodeTagged = SchemaParser.decodeUnknownSync(Schema.Union([ |
| 551 | Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), |
| 552 | Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) |
| 553 | ])) |
| 554 | deepStrictEqual( |
| 555 | decodeTagged({ kind: "b", value: 1, extra: true }), |
| 556 | { kind: "b", value: 1 } |
| 557 | ) |
| 558 | |
| 559 | const decodeUntagged = SchemaParser.decodeUnknownSync(Schema.Union([ |
| 560 | Schema.Struct({ first: Schema.String }), |
| 561 | Schema.Struct({ second: Schema.String }) |
| 562 | ])) |
| 563 | deepStrictEqual( |
| 564 | decodeUntagged({ first: "a", second: "b" }), |
| 565 | { first: "a" } |
| 566 | ) |
| 567 | |
| 568 | const decodeOneOf = SchemaParser.decodeUnknownSync(Schema.Union([ |
| 569 | Schema.Struct({ first: Schema.String }), |
| 570 | Schema.Struct({ second: Schema.String }) |
| 571 | ], { mode: "oneOf" })) |
| 572 | throws(() => decodeOneOf({ first: "a", second: "b" }), (error) => { |
| 573 | assertSchemaIssueError(error, "Expected exactly one member to match") |
| 574 | }) |
| 575 | }) |
| 576 | |
| 577 | it("compiles optional object properties", () => { |
| 578 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 579 | required: Schema.String, |
| 580 | optional: Schema.optionalKey(Schema.Number), |
| 581 | undefined: Schema.optionalKey(Schema.Undefined) |
| 582 | })) |
| 583 | |
| 584 | deepStrictEqual(decode({ required: "a" }), { required: "a" }) |
| 585 | deepStrictEqual( |
| 586 | decode({ required: "a", optional: 1, undefined, extra: true }), |
| 587 | { required: "a", optional: 1, undefined } |
| 588 | ) |
| 589 | throws(() => decode({ required: "a", optional: "invalid" }), (error) => { |
| 590 | assertSchemaIssueError(error, `Expected number\n at ["optional"]`) |
| 591 | }) |
| 592 | }) |
| 593 | |
| 594 | it("compiles string records", () => { |
| 595 | const decode = SchemaParser.decodeUnknownSync( |
| 596 | Schema.Record(Schema.String, Schema.Struct({ count: Schema.Number })) |
| 597 | ) |
| 598 | deepStrictEqual( |
| 599 | decode({ a: { count: 1 }, b: { count: 2 }, extra: { count: 3, ignored: true } }), |
| 600 | { a: { count: 1 }, b: { count: 2 }, extra: { count: 3 } } |
| 601 | ) |
| 602 | throws(() => decode({ a: { count: "invalid" } }), (error) => { |
| 603 | assertSchemaIssueError(error, `Expected number\n at ["a"]["count"]`) |
| 604 | }) |
| 605 | }) |
| 606 | |
| 607 | it("compiles symbol and template-literal records", () => { |
| 608 | const symbol = Symbol("key") |
| 609 | const decodeSymbols = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Symbol, Schema.Number)) |
| 610 | const symbolOutput = decodeSymbols({ text: "ignored", [symbol]: 1 }) |
| 611 | strictEqual(symbolOutput[symbol], 1) |
| 612 | deepStrictEqual(Reflect.ownKeys(symbolOutput), [symbol]) |
| 613 | |
| 614 | const decodeTemplates = SchemaParser.decodeUnknownSync( |
| 615 | Schema.Record(Schema.TemplateLiteral(["data-", Schema.String]), Schema.Number) |
| 616 | ) |
| 617 | deepStrictEqual( |
| 618 | decodeTemplates({ "data-a": 1, ignored: 2, "data-b": 3 }), |
| 619 | { "data-a": 1, "data-b": 3 } |
| 620 | ) |
| 621 | }) |
| 622 | |
| 623 | it("compiles fixed properties with index signatures", () => { |
| 624 | const decode = SchemaParser.decodeUnknownSync( |
| 625 | Schema.StructWithRest( |
| 626 | Schema.Struct({ fixed: Schema.Trim }), |
| 627 | [Schema.Record(Schema.String, Schema.String)] |
| 628 | ) |
| 629 | ) |
| 630 | deepStrictEqual(decode({ fixed: " value ", other: "other" }), { fixed: "value", other: "other" }) |
| 631 | }) |
| 632 | |
| 633 | it("compiles decoded index keys", () => { |
| 634 | const decodeNumbers = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Number, Schema.Number)) |
| 635 | deepStrictEqual(decodeNumbers({ 1: 1, other: "ignored" }), { 1: 1 }) |
| 636 | |
| 637 | const decodeCamelCase = SchemaParser.decodeUnknownSync( |
| 638 | Schema.Record( |
| 639 | Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.toUpperCase())), |
| 640 | Schema.Number |
| 641 | ) |
| 642 | ) |
| 643 | deepStrictEqual(decodeCamelCase({ a: 1, b: 2 }), { A: 1, B: 2 }) |
| 644 | }) |
| 645 | |
| 646 | it("compiles encoding checks", () => { |
| 647 | const checked = Schema.Struct({ value: Schema.String }).pipe( |
| 648 | Schema.flip, |
| 649 | Schema.check(Schema.makeFilter((input) => input.value.length > 1)), |
| 650 | Schema.flip |
| 651 | ) |
| 652 | const decode = SchemaParser.decodeUnknownSync(checked) |
| 653 | deepStrictEqual(decode({ value: "valid" }), { value: "valid" }) |
| 654 | throws(() => decode({ value: "" })) |
| 655 | }) |
| 656 | |
| 657 | it("runs checks against decoded output", () => { |
| 658 | const decodeString = SchemaParser.decodeUnknownSync( |
| 659 | Schema.String.check(Schema.isMinLength(2)) |
| 660 | ) |
| 661 | strictEqual(decodeString("ab"), "ab") |
| 662 | throws(() => decodeString("a"), (error) => { |
| 663 | assertSchemaIssueError(error, "Expected a value with a length of at least 2") |
| 664 | }) |
| 665 | |
| 666 | const decodeObject = SchemaParser.decodeUnknownSync( |
| 667 | Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) |
| 668 | ) |
| 669 | deepStrictEqual(decodeObject({ value: "a", extra: true }), { value: "a" }) |
| 670 | }) |
| 671 | |
| 672 | it("runs compiled type guard checks against decoded output", () => { |
| 673 | const checked = Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) |
| 674 | const isRoot = SchemaParser.is(checked) |
| 675 | const isNested = SchemaParser.is(Schema.Struct({ nested: checked })) |
| 676 | |
| 677 | strictEqual(isRoot({ value: "a", extra: true }), true) |
| 678 | strictEqual(isNested({ nested: { value: "a", extra: true }, extra: true }), true) |
| 679 | strictEqual(isNested({ nested: { value: 1 } }), false) |
| 680 | }) |
| 681 | |
| 682 | it("uses compiled checkpoints around interpreted declarations and transformations", () => { |
| 683 | const date = new Date(0) |
| 684 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ |
| 685 | date: Schema.instanceOf(Date), |
| 686 | count: Schema.FiniteFromString |
| 687 | })) |
| 688 | |
| 689 | const output = decode({ date, count: "1", extra: true }) |
| 690 | strictEqual(output.date, date) |
| 691 | strictEqual(output.count, 1) |
| 692 | deepStrictEqual(Reflect.ownKeys(output), ["date", "count"]) |
| 693 | }) |
| 694 | |
| 695 | it("uses compiled checkpoints inside root encoding chains", () => { |
| 696 | const decodeNumber = SchemaParser.decodeUnknownSync(Schema.FiniteFromString) |
| 697 | strictEqual(decodeNumber("1"), 1) |
| 698 | throws(() => decodeNumber("invalid"), (error) => { |
| 699 | assertSchemaIssueError(error, "Expected a finite number") |
| 700 | }) |
| 701 | |
| 702 | const decodeJson = SchemaParser.decodeUnknownSync( |
| 703 | Schema.fromJsonString(Schema.Struct({ value: Schema.Number })) |
| 704 | ) |
| 705 | deepStrictEqual(decodeJson("{\"value\":1,\"extra\":true}"), { value: 1 }) |
| 706 | }) |
| 707 | |
| 708 | it("does not replay transformations when a compiled checkpoint fails", () => { |
| 709 | let sourceChecks = 0 |
| 710 | let firstTransformations = 0 |
| 711 | let secondTransformations = 0 |
| 712 | let checks = 0 |
| 713 | const schema = Schema.Struct({ |
| 714 | value: Schema.String.check(Schema.makeFilter((value) => { |
| 715 | sourceChecks++ |
| 716 | return value !== "blocked" |
| 717 | })).pipe( |
| 718 | Schema.decodeTo( |
| 719 | Schema.Number.check(Schema.makeFilter((value) => { |
| 720 | checks++ |
| 721 | return value > 0 |
| 722 | })), |
| 723 | SchemaTransformation.transform({ |
| 724 | decode: (value) => { |
| 725 | firstTransformations++ |
| 726 | return Number(value) |
| 727 | }, |
| 728 | encode: String |
| 729 | }) |
| 730 | ), |
| 731 | Schema.decodeTo( |
| 732 | Schema.String, |
| 733 | SchemaTransformation.transform({ |
| 734 | decode: (value) => { |
| 735 | secondTransformations++ |
| 736 | return String(value) |
| 737 | }, |
| 738 | encode: Number |
| 739 | }) |
| 740 | ) |
| 741 | ) |
| 742 | }) |
| 743 | |
| 744 | throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "blocked" })) |
| 745 | strictEqual(sourceChecks, 1) |
| 746 | strictEqual(firstTransformations, 0) |
| 747 | strictEqual(secondTransformations, 0) |
| 748 | |
| 749 | sourceChecks = 0 |
| 750 | throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "-1" })) |
| 751 | strictEqual(sourceChecks, 1) |
| 752 | strictEqual(firstTransformations, 1) |
| 753 | strictEqual(secondTransformations, 0) |
| 754 | strictEqual(checks, 1) |
| 755 | }) |
| 756 | |
| 757 | it("preserves mixed causes from interpreted encoding chains", () => { |
| 758 | const cause = Cause.combine( |
| 759 | Cause.fail(new SchemaIssue.InvalidValue({ message: "schema issue" })), |
| 760 | Cause.die(new Error("defect")) |
| 761 | ) |
| 762 | const schema = Schema.String.pipe(Schema.decode({ |
| 763 | decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), |
| 764 | encode: SchemaGetter.passthrough() |
| 765 | })) |
| 766 | |
| 767 | throws(() => SchemaParser.decodeUnknownSync(schema)("value"), (error) => { |
| 768 | assert(error instanceof Error) |
| 769 | strictEqual(error.message, "Sync adapter can only throw schema issues") |
| 770 | const issue = Cause.findError(error.cause as Cause.Cause<SchemaIssue.Issue>) |
| 771 | assert(Result.isSuccess(issue)) |
| 772 | strictEqual(issue.success._tag, "Encoding") |
| 773 | assert(Cause.hasDies(error.cause as Cause.Cause<never>)) |
| 774 | }) |
| 775 | }) |
| 776 | |
| 777 | it("keeps Suspend parsers lazy", () => { |
| 778 | interface Category { |
| 779 | readonly value: string |
| 780 | readonly children: ReadonlyArray<Category> |
| 781 | } |
| 782 | let evaluations = 0 |
| 783 | const schema: Schema.Codec<Category> = Schema.Struct({ |
| 784 | value: Schema.String, |
| 785 | children: Schema.Array(Schema.suspend((): Schema.Codec<Category> => { |
| 786 | evaluations++ |
| 787 | return schema |
| 788 | })) |
| 789 | }) |
| 790 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 791 | |
| 792 | deepStrictEqual(decode({ value: "root", children: [] }), { value: "root", children: [] }) |
| 793 | strictEqual(evaluations, 0) |
| 794 | deepStrictEqual( |
| 795 | decode({ value: "root", children: [{ value: "child", children: [] }] }), |
| 796 | { value: "root", children: [{ value: "child", children: [] }] } |
| 797 | ) |
| 798 | strictEqual(evaluations, 1) |
| 799 | }) |
| 800 | |
| 801 | it("does not replay declaration defects", () => { |
| 802 | const defect = new Error("declaration defect") |
| 803 | let runs = 0 |
| 804 | const declaration = Schema.declareConstructor<unknown>()([], () => () => { |
| 805 | runs++ |
| 806 | return Effect.die(defect) |
| 807 | }) |
| 808 | const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ declaration })) |
| 809 | |
| 810 | throws(() => decode({ declaration: "value" }), (error) => { |
| 811 | assert(error instanceof Error) |
| 812 | strictEqual(error.message, "Sync adapter can only throw schema issues") |
| 813 | assert(Cause.hasDies(error.cause as Cause.Cause<never>)) |
| 814 | }) |
| 815 | strictEqual(runs, 1) |
| 816 | }) |
| 817 | }) |