1import { assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
2import { Context, Effect, type Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect"
3import type { StandardSchemaV1 } from "effect/StandardSchema"
4import { describe, it } from "vitest"
5
6function validate<I, A>(
7 schema: StandardSchemaV1<I, A>,
8 input: unknown
9): StandardSchemaV1.Result<A> | Promise<StandardSchemaV1.Result<A>> {
10 return schema["~standard"].validate(input)
11}
12
13const isPromise = (value: unknown): value is Promise<unknown> => value instanceof Promise
14
15const expectSuccess = <A>(result: StandardSchemaV1.Result<A>, a: A) => {
16 deepStrictEqual(result, { value: a })
17}
18
19const expectSyncSuccess = <I, A>(
20 schema: StandardSchemaV1<I, A>,
21 input: unknown,
22 a: A
23) => {
24 const result = validate(schema, input)
25 if (isPromise(result)) {
26 throw new Error("Expected value, got promise")
27 } else {
28 expectSuccess(result, a)
29 }
30}
31
32const expectFailure = <A>(
33 result: StandardSchemaV1.Result<A>,
34 issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
35) => {
36 if (result.issues !== undefined) {
37 if (Predicate.isFunction(issues)) {
38 issues(result.issues)
39 } else {
40 deepStrictEqual(
41 result.issues.map((issue) => ({
42 message: issue.message,
43 path: issue.path
44 })),
45 issues
46 )
47 }
48 } else {
49 throw new Error("Expected issues, got undefined")
50 }
51}
52
53const expectAsyncSuccess = async <I, A>(
54 schema: StandardSchemaV1<I, A>,
55 input: unknown,
56 a: A
57) => {
58 const result = validate(schema, input)
59 if (isPromise(result)) {
60 expectSuccess(await result, a)
61 } else {
62 throw new Error("Expected promise, got value")
63 }
64}
65
66const expectSyncFailure = <I, A>(
67 schema: StandardSchemaV1<I, A>,
68 input: unknown,
69 issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
70) => {
71 const result = validate(schema, input)
72 if (isPromise(result)) {
73 throw new Error("Expected value, got promise")
74 } else {
75 expectFailure(result, issues)
76 }
77}
78
79const expectAsyncFailure = async <I, A>(
80 schema: StandardSchemaV1<I, A>,
81 input: unknown,
82 issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
83) => {
84 const result = validate(schema, input)
85 if (isPromise(result)) {
86 expectFailure(await result, issues)
87 } else {
88 throw new Error("Expected promise, got value")
89 }
90}
91
92const AsyncString = Schema.String.pipe(Schema.decode({
93 decode: SchemaGetter.transformOptionalEffect((os: Option.Option<string>) =>
94 Effect.gen(function*() {
95 yield* Effect.sleep("10 millis")
96 return os
97 })
98 ),
99 encode: SchemaGetter.passthrough()
100}))
101
102const AsyncNonEmptyString = AsyncString.check(Schema.isNonEmpty())
103
104describe("toStandardSchemaV1", () => {
105 it("should return a Standard Schema V1 schema", () => {
106 const schema = Schema.FiniteFromString
107 const standardSchema = Schema.toStandardSchemaV1(schema)
108 assertTrue(Schema.isSchema(standardSchema))
109 })
110
111 it("should support both standards", () => {
112 const schema = Schema.String
113 const both = Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema))
114 expectSyncSuccess(both, "a", "a")
115 })
116
117 it("sync decoding", () => {
118 const schema = Schema.NonEmptyString
119 const standardSchema = Schema.toStandardSchemaV1(schema)
120 expectSyncSuccess(standardSchema, "a", "a")
121 expectSyncFailure(standardSchema, null, [
122 {
123 message: "Expected string",
124 path: []
125 }
126 ])
127 expectSyncFailure(standardSchema, "", [
128 {
129 message: `Expected a value with a length of at least 1`,
130 path: []
131 }
132 ])
133 })
134
135 it("async decoding", async () => {
136 const schema = AsyncNonEmptyString
137 const standardSchema = Schema.toStandardSchemaV1(schema)
138 await expectAsyncSuccess(standardSchema, "a", "a")
139 expectSyncFailure(standardSchema, null, [
140 {
141 message: "Expected string",
142 path: []
143 }
144 ])
145 await expectAsyncFailure(standardSchema, "", [
146 {
147 message: `Expected a value with a length of at least 1`,
148 path: []
149 }
150 ])
151 })
152
153 describe("missing dependencies", () => {
154 class MagicNumber extends Context.Service<MagicNumber, number>()("MagicNumber") {}
155
156 it("sync decoding should throw", () => {
157 const DepString = Schema.Number.pipe(Schema.decode({
158 decode: SchemaGetter.transformEffect((n) =>
159 Effect.gen(function*() {
160 const magicNumber = yield* MagicNumber
161 return n * magicNumber
162 })
163 ),
164 encode: SchemaGetter.passthrough()
165 }))
166
167 const schema = DepString
168 const standardSchema = Schema.toStandardSchemaV1(schema as any)
169 expectSyncFailure(standardSchema, 1, (issues) => {
170 strictEqual(issues.length, 1)
171 deepStrictEqual(issues[0].path, undefined)
172 assertTrue(issues[0].message.includes("Service not found: MagicNumber"))
173 })
174 })
175
176 it("async decoding should report a missing dependency", () => {
177 const DepString = Schema.Number.pipe(Schema.decode({
178 decode: SchemaGetter.transformEffect((n) =>
179 Effect.gen(function*() {
180 const magicNumber = yield* MagicNumber
181 yield* Effect.sleep("10 millis")
182 return n * magicNumber
183 })
184 ),
185 encode: SchemaGetter.passthrough()
186 }))
187
188 const schema = DepString
189 const standardSchema = Schema.toStandardSchemaV1(schema as any)
190 expectSyncFailure(standardSchema, 1, (issues) => {
191 strictEqual(issues.length, 1)
192 deepStrictEqual(issues[0].path, undefined)
193 assertTrue(issues[0].message.includes("Service not found: MagicNumber"))
194 })
195 })
196 })
197
198 it("by default should return all issues", () => {
199 const schema = Schema.Struct({
200 a: Schema.NonEmptyString,
201 b: Schema.NonEmptyString
202 })
203 const standardSchema = Schema.toStandardSchemaV1(schema)
204 expectSyncSuccess(standardSchema, { a: "a", b: "b" }, { a: "a", b: "b" })
205 expectSyncFailure(standardSchema, null, [
206 {
207 message: "Expected object",
208 path: []
209 }
210 ])
211 expectSyncFailure(standardSchema, { a: "a", b: "" }, [
212 {
213 message: `Expected a value with a length of at least 1`,
214 path: ["b"]
215 }
216 ])
217 expectSyncFailure(standardSchema, { a: "", b: "b" }, [
218 {
219 message: `Expected a value with a length of at least 1`,
220 path: ["a"]
221 }
222 ])
223 expectSyncFailure(standardSchema, { a: "", b: "" }, [
224 {
225 message: `Expected a value with a length of at least 1`,
226 path: ["a"]
227 },
228 {
229 message: `Expected a value with a length of at least 1`,
230 path: ["b"]
231 }
232 ])
233 })
234
235 it("with parseOptions: { errors: 'first' } should return only the first issue", () => {
236 const schema = Schema.Struct({
237 a: Schema.NonEmptyString,
238 b: Schema.NonEmptyString
239 })
240 const standardSchema = Schema.toStandardSchemaV1(schema, { parseOptions: { errors: "first" } })
241 expectSyncFailure(standardSchema, { a: "", b: "" }, [
242 {
243 message: `Expected a value with a length of at least 1`,
244 path: ["a"]
245 }
246 ])
247 })
248
249 describe("should respect the `message` annotation", () => {
250 describe("String", () => {
251 it("String & annotation", () => {
252 const schema = Schema.String.annotate({ message: "Custom message" })
253 const standardSchema = Schema.toStandardSchemaV1(schema)
254 expectSyncFailure(standardSchema, null, [
255 {
256 message: "Custom message",
257 path: []
258 }
259 ])
260 })
261
262 it("String & annotation & isNonEmpty", () => {
263 const schema = Schema.String.annotate({ message: "Custom message" }).check(Schema.isNonEmpty())
264 const standardSchema = Schema.toStandardSchemaV1(schema)
265 expectSyncFailure(standardSchema, null, [
266 {
267 message: "Custom message",
268 path: []
269 }
270 ])
271 })
272
273 it("String & isNonEmpty & annotation", () => {
274 const schema = Schema.String.check(Schema.isNonEmpty()).annotate({ message: "Custom message" })
275 const standardSchema = Schema.toStandardSchemaV1(schema)
276 expectSyncFailure(standardSchema, null, [
277 {
278 message: "Expected string",
279 path: []
280 }
281 ])
282 expectSyncFailure(standardSchema, "", [
283 {
284 message: "Custom message",
285 path: []
286 }
287 ])
288 })
289
290 it("String & isNonEmpty(annotation)", () => {
291 const schema = Schema.String.check(Schema.isNonEmpty({ message: "Custom message" }))
292 const standardSchema = Schema.toStandardSchemaV1(schema)
293 expectSyncFailure(standardSchema, null, [
294 {
295 message: "Expected string",
296 path: []
297 }
298 ])
299 expectSyncFailure(standardSchema, "", [
300 {
301 message: "Custom message",
302 path: []
303 }
304 ])
305 })
306
307 it("String & annotation & isNonEmpty & annotation", () => {
308 const schema = Schema.String.annotate({ message: "Custom message" }).check(Schema.isNonEmpty()).annotate({
309 message: "Custom message 2"
310 })
311 const standardSchema = Schema.toStandardSchemaV1(schema)
312 expectSyncFailure(standardSchema, null, [
313 {
314 message: "Custom message",
315 path: []
316 }
317 ])
318 expectSyncFailure(standardSchema, "", [
319 {
320 message: "Custom message 2",
321 path: []
322 }
323 ])
324 })
325
326 it("String & annotation & isNonEmpty(annotation)", () => {
327 const schema = Schema.String.annotate({ message: "Custom message" }).check(
328 Schema.isNonEmpty({ message: "Custom message 2" })
329 )
330 const standardSchema = Schema.toStandardSchemaV1(schema)
331 expectSyncFailure(standardSchema, null, [
332 {
333 message: "Custom message",
334 path: []
335 }
336 ])
337 expectSyncFailure(standardSchema, "", [
338 {
339 message: "Custom message 2",
340 path: []
341 }
342 ])
343 })
344
345 it("String & annotation & isNonEmpty(annotation) & isMaxLength(annotation)", () => {
346 const schema = Schema.String.annotate({ message: "Custom message" })
347 .check(Schema.isNonEmpty({ message: "Custom message 2" }))
348 .check(Schema.isMaxLength(2, { message: "Custom message 3" }))
349 const standardSchema = Schema.toStandardSchemaV1(schema)
350 expectSyncFailure(standardSchema, null, [
351 {
352 message: "Custom message",
353 path: []
354 }
355 ])
356 expectSyncFailure(standardSchema, "", [
357 {
358 message: "Custom message 2",
359 path: []
360 }
361 ])
362 expectSyncFailure(standardSchema, "abc", [
363 {
364 message: "Custom message 3",
365 path: []
366 }
367 ])
368 })
369 })
370
371 describe("Struct", () => {
372 it("messageMissingKey", () => {
373 const schema = Schema.Struct({
374 a: Schema.String.annotateKey({ messageMissingKey: "Custom message" })
375 })
376 const standardSchema = Schema.toStandardSchemaV1(schema)
377 expectSyncFailure(standardSchema, {}, [
378 {
379 message: "Custom message",
380 path: ["a"]
381 }
382 ])
383 })
384
385 it("messageUnexpectedKey", () => {
386 const schema = Schema.Struct({
387 a: Schema.String
388 }).annotate({ messageUnexpectedKey: "Custom message" })
389 const standardSchema = Schema.toStandardSchemaV1(schema, {
390 parseOptions: { onExcessProperty: "error" }
391 })
392 expectSyncFailure(standardSchema, { a: "a", b: "b" }, [
393 {
394 message: "Custom message",
395 path: ["b"]
396 }
397 ])
398 })
399 })
400
401 describe("Tuple", () => {
402 it("messageMissingKey", () => {
403 const schema = Schema.Tuple([
404 Schema.String.annotateKey({ messageMissingKey: "Custom message" })
405 ])
406 const standardSchema = Schema.toStandardSchemaV1(schema)
407 expectSyncFailure(standardSchema, [], [
408 {
409 message: "Custom message",
410 path: [0]
411 }
412 ])
413 })
414
415 it("messageUnexpectedKey", () => {
416 const schema = Schema.Tuple([
417 Schema.String
418 ]).annotate({ messageUnexpectedKey: "Custom message" })
419 const standardSchema = Schema.toStandardSchemaV1(schema, {
420 parseOptions: { onExcessProperty: "error" }
421 })
422 expectSyncFailure(standardSchema, ["a", "b"], [
423 {
424 message: "Custom message",
425 path: [1]
426 }
427 ])
428 })
429 })
430
431 describe("Union", () => {
432 it("Literals", () => {
433 const schema = Schema.Literals(["a", "b"]).annotate({ message: "Custom message" })
434 const standardSchema = Schema.toStandardSchemaV1(schema)
435 expectSyncFailure(standardSchema, null, [
436 {
437 message: "Custom message",
438 path: []
439 }
440 ])
441 expectSyncFailure(standardSchema, "-", [
442 {
443 message: "Custom message",
444 path: []
445 }
446 ])
447 })
448 })
449 })
450
451 describe("treeLeafHook & verboseCheckHook", () => {
452 it("String", () => {
453 const schema = Schema.String
454 const standardSchema = Schema.toStandardSchemaV1(schema, {
455 leafHook: SchemaIssue.defaultLeafHook
456 })
457 expectSyncFailure(standardSchema, null, [
458 {
459 message: "Expected string",
460 path: []
461 }
462 ])
463 })
464
465 it("NonEmptyString", () => {
466 const schema = Schema.NonEmptyString
467 const standardSchema = Schema.toStandardSchemaV1(schema, {
468 leafHook: SchemaIssue.defaultLeafHook
469 })
470 expectSyncFailure(standardSchema, "", [
471 {
472 message: `Expected a value with a length of at least 1`,
473 path: []
474 }
475 ])
476 })
477 })
478})