Skip to content

Commit 8755936

Browse files
gcantitim-smart
authored andcommitted
Schema: Add standardSchemaV1 API to Generate a Standard Schema (#4359)
Co-authored-by: Tim <hello@timsmart.co>
1 parent 9c32cc0 commit 8755936

File tree

5 files changed

+354
-0
lines changed

5 files changed

+354
-0
lines changed

.changeset/twenty-owls-hunt.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"effect": minor
3+
---
4+
5+
Schema: Add `standardSchemaV1` API to Generate a [Standard Schema v1](https://standardschema.dev/).
6+
7+
**Example**
8+
9+
```ts
10+
import { Schema } from "effect"
11+
12+
const schema = Schema.Struct({
13+
name: Schema.String
14+
})
15+
16+
// ┌─── StandardSchemaV1<{ readonly name: string; }>
17+
//
18+
const standardSchema = Schema.standardSchemaV1(schema)
19+
```

packages/effect/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"zod": "^3.24.1"
5151
},
5252
"dependencies": {
53+
"@standard-schema/spec": "^1.0.0",
5354
"fast-check": "^3.23.1"
5455
}
5556
}

packages/effect/src/Schema.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @since 3.10.0
33
*/
44

5+
import type { StandardSchemaV1 } from "@standard-schema/spec"
56
import type { ArbitraryAnnotation, ArbitraryGenerationContext, LazyArbitrary } from "./Arbitrary.js"
67
import * as array_ from "./Array.js"
78
import * as bigDecimal_ from "./BigDecimal.js"
@@ -44,6 +45,7 @@ import type * as pretty_ from "./Pretty.js"
4445
import * as record_ from "./Record.js"
4546
import * as redacted_ from "./Redacted.js"
4647
import * as Request from "./Request.js"
48+
import * as scheduler_ from "./Scheduler.js"
4749
import type { ParseOptions } from "./SchemaAST.js"
4850
import * as AST from "./SchemaAST.js"
4951
import * as sortedSet_ from "./SortedSet.js"
@@ -128,6 +130,81 @@ const variance = {
128130
_R: (_: never) => _
129131
}
130132

133+
const makeStandardResult = <A>(exit: exit_.Exit<StandardSchemaV1.Result<A>>): StandardSchemaV1.Result<A> =>
134+
exit_.isSuccess(exit) ? exit.value : makeStandardFailureResult(cause_.pretty(exit.cause))
135+
136+
const makeStandardFailureResult = (message: string): StandardSchemaV1.FailureResult => ({
137+
issues: [{ message }]
138+
})
139+
140+
const makeStandardFailureFromParseIssue = (
141+
issue: ParseResult.ParseIssue
142+
): Effect.Effect<StandardSchemaV1.FailureResult> =>
143+
Effect.map(ParseResult.ArrayFormatter.formatIssue(issue), (issues) => ({
144+
issues: issues.map((issue) => ({
145+
path: issue.path,
146+
message: issue.message
147+
}))
148+
}))
149+
150+
/**
151+
* Returns a "Standard Schema" object conforming to the [Standard Schema
152+
* v1](https://standardschema.dev/) specification.
153+
*
154+
* This function creates a schema whose `validate` method attempts to decode and
155+
* validate the provided input synchronously. If the underlying `Schema`
156+
* includes any asynchronous components (e.g., asynchronous message resolutions
157+
* or checks), then validation will necessarily return a `Promise` instead.
158+
*
159+
* Any detected defects will be reported via a single issue containing no
160+
* `path`.
161+
*
162+
* @example
163+
* ```ts
164+
* import { Schema } from "effect"
165+
*
166+
* const schema = Schema.Struct({
167+
* name: Schema.String
168+
* })
169+
*
170+
* // ┌─── StandardSchemaV1<{ readonly name: string; }>
171+
* // ▼
172+
* const standardSchema = Schema.standardSchemaV1(schema)
173+
* ```
174+
*
175+
* @category Standard Schema
176+
* @since 3.13.0
177+
*/
178+
export const standardSchemaV1 = <A, I>(schema: Schema<A, I, never>): StandardSchemaV1<I, A> => {
179+
const decodeUnknown = ParseResult.decodeUnknown(schema)
180+
return {
181+
"~standard": {
182+
version: 1,
183+
vendor: "effect",
184+
validate(value) {
185+
const scheduler = new scheduler_.SyncScheduler()
186+
const fiber = Effect.runFork(
187+
Effect.matchEffect(decodeUnknown(value), {
188+
onFailure: makeStandardFailureFromParseIssue,
189+
onSuccess: (value) => Effect.succeed({ value })
190+
}),
191+
{ scheduler }
192+
)
193+
scheduler.flush()
194+
const exit = fiber.unsafePoll()
195+
if (exit) {
196+
return makeStandardResult(exit)
197+
}
198+
return new Promise((resolve) => {
199+
fiber.addObserver((exit) => {
200+
resolve(makeStandardResult(exit))
201+
})
202+
})
203+
}
204+
}
205+
}
206+
}
207+
131208
interface AllAnnotations<A, TypeParameters extends ReadonlyArray<any>>
132209
extends Annotations.Schema<A, TypeParameters>, PropertySignature.Annotations<A>
133210
{}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import type { StandardSchemaV1 } from "@standard-schema/spec"
2+
import { Context, Effect, ParseResult, Predicate, Schema } from "effect"
3+
import { assertTrue, deepStrictEqual, strictEqual } from "effect/test/util"
4+
import { describe, it } from "vitest"
5+
import { AsyncString } from "../TestUtils.js"
6+
7+
function validate<I, A>(
8+
schema: StandardSchemaV1<I, A>,
9+
input: unknown
10+
): StandardSchemaV1.Result<A> | Promise<StandardSchemaV1.Result<A>> {
11+
return schema["~standard"].validate(input)
12+
}
13+
14+
const isPromise = (value: unknown): value is Promise<unknown> => value instanceof Promise
15+
16+
const expectSuccess = async <A>(
17+
result: StandardSchemaV1.Result<A>,
18+
a: A
19+
) => {
20+
deepStrictEqual(result, { value: a })
21+
}
22+
23+
const expectFailure = async <A>(
24+
result: StandardSchemaV1.Result<A>,
25+
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
26+
) => {
27+
if (result.issues !== undefined) {
28+
if (Predicate.isFunction(issues)) {
29+
issues(result.issues)
30+
} else {
31+
deepStrictEqual(result.issues, issues)
32+
}
33+
} else {
34+
throw new Error("Expected issues, got undefined")
35+
}
36+
}
37+
38+
const expectSyncSuccess = <I, A>(
39+
schema: StandardSchemaV1<I, A>,
40+
input: unknown,
41+
a: A
42+
) => {
43+
const result = validate(schema, input)
44+
if (isPromise(result)) {
45+
throw new Error("Expected value, got promise")
46+
} else {
47+
expectSuccess(result, a)
48+
}
49+
}
50+
51+
const expectAsyncSuccess = async <I, A>(
52+
schema: StandardSchemaV1<I, A>,
53+
input: unknown,
54+
a: A
55+
) => {
56+
const result = validate(schema, input)
57+
if (isPromise(result)) {
58+
expectSuccess(await result, a)
59+
} else {
60+
throw new Error("Expected promise, got value")
61+
}
62+
}
63+
64+
const expectSyncFailure = <I, A>(
65+
schema: StandardSchemaV1<I, A>,
66+
input: unknown,
67+
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
68+
) => {
69+
const result = validate(schema, input)
70+
if (isPromise(result)) {
71+
throw new Error("Expected value, got promise")
72+
} else {
73+
expectFailure(result, issues)
74+
}
75+
}
76+
77+
const expectAsyncFailure = async <I, A>(
78+
schema: StandardSchemaV1<I, A>,
79+
input: unknown,
80+
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
81+
) => {
82+
const result = validate(schema, input)
83+
if (isPromise(result)) {
84+
expectFailure(await result, issues)
85+
} else {
86+
throw new Error("Expected promise, got value")
87+
}
88+
}
89+
90+
const AsyncNonEmptyString = AsyncString.pipe(Schema.minLength(1))
91+
92+
describe("standardSchemaV1", () => {
93+
it("sync decoding + sync issue formatting", () => {
94+
const schema = Schema.NonEmptyString
95+
const standardSchema = Schema.standardSchemaV1(schema)
96+
expectSyncSuccess(standardSchema, "a", "a")
97+
expectSyncFailure(standardSchema, null, [
98+
{
99+
message: "Expected string, actual null",
100+
path: []
101+
}
102+
])
103+
expectSyncFailure(standardSchema, "", [
104+
{
105+
message: `Expected a non empty string, actual ""`,
106+
path: []
107+
}
108+
])
109+
})
110+
111+
it("sync decoding + sync custom message", () => {
112+
const schema = Schema.NonEmptyString.annotations({ message: () => Effect.succeed("my message") })
113+
const standardSchema = Schema.standardSchemaV1(schema)
114+
expectSyncSuccess(standardSchema, "a", "a")
115+
expectSyncFailure(standardSchema, null, [
116+
{
117+
message: "Expected string, actual null",
118+
path: []
119+
}
120+
])
121+
expectSyncFailure(standardSchema, "", [
122+
{
123+
message: "my message",
124+
path: []
125+
}
126+
])
127+
})
128+
129+
it("sync decoding + async custom message", async () => {
130+
const schema = Schema.NonEmptyString.annotations({
131+
message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis"))
132+
})
133+
const standardSchema = Schema.standardSchemaV1(schema)
134+
expectSyncSuccess(standardSchema, "a", "a")
135+
await expectAsyncFailure(standardSchema, null, [
136+
{
137+
message: "Expected string, actual null",
138+
path: []
139+
}
140+
])
141+
await expectAsyncFailure(standardSchema, "", [
142+
{
143+
message: "my message",
144+
path: []
145+
}
146+
])
147+
})
148+
149+
it("async decoding + sync issue formatting", async () => {
150+
const schema = AsyncNonEmptyString
151+
const standardSchema = Schema.standardSchemaV1(schema)
152+
await expectAsyncSuccess(standardSchema, "a", "a")
153+
expectSyncFailure(standardSchema, null, [
154+
{
155+
message: "Expected string, actual null",
156+
path: []
157+
}
158+
])
159+
await expectAsyncFailure(standardSchema, "", [
160+
{
161+
message: `Expected a string at least 1 character(s) long, actual ""`,
162+
path: []
163+
}
164+
])
165+
})
166+
167+
it("async decoding + sync custom message", async () => {
168+
const schema = AsyncNonEmptyString.annotations({ message: () => Effect.succeed("my message") })
169+
const standardSchema = Schema.standardSchemaV1(schema)
170+
await expectAsyncSuccess(standardSchema, "a", "a")
171+
expectSyncFailure(standardSchema, null, [
172+
{
173+
message: "Expected string, actual null",
174+
path: []
175+
}
176+
])
177+
await expectAsyncFailure(standardSchema, "", [
178+
{
179+
message: "my message",
180+
path: []
181+
}
182+
])
183+
})
184+
185+
it("async decoding + async custom message", async () => {
186+
const schema = AsyncNonEmptyString.annotations({
187+
message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis"))
188+
})
189+
const standardSchema = Schema.standardSchemaV1(schema)
190+
await expectAsyncSuccess(standardSchema, "a", "a")
191+
await expectAsyncFailure(standardSchema, null, [
192+
{
193+
message: "Expected string, actual null",
194+
path: []
195+
}
196+
])
197+
await expectAsyncFailure(standardSchema, "", [
198+
{
199+
message: "my message",
200+
path: []
201+
}
202+
])
203+
})
204+
205+
describe("missing dependencies", () => {
206+
class MagicNumber extends Context.Tag("Min")<MagicNumber, number>() {}
207+
208+
it("sync decoding should throw", () => {
209+
const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, {
210+
strict: true,
211+
decode: (n) =>
212+
Effect.gen(function*(_) {
213+
const magicNumber = yield* MagicNumber
214+
return n * magicNumber
215+
}),
216+
encode: ParseResult.succeed
217+
})
218+
219+
const schema = DepString
220+
const standardSchema = Schema.standardSchemaV1(schema as any)
221+
expectSyncFailure(standardSchema, 1, (issues) => {
222+
strictEqual(issues.length, 1)
223+
deepStrictEqual(issues[0].path, undefined)
224+
assertTrue(issues[0].message.includes("Service not found: Min"))
225+
})
226+
})
227+
228+
it("async decoding should throw", () => {
229+
const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, {
230+
strict: true,
231+
decode: (n) =>
232+
Effect.gen(function*(_) {
233+
const magicNumber = yield* MagicNumber
234+
yield* Effect.sleep("10 millis")
235+
return n * magicNumber
236+
}),
237+
encode: ParseResult.succeed
238+
})
239+
240+
const schema = DepString
241+
const standardSchema = Schema.standardSchemaV1(schema as any)
242+
expectSyncFailure(standardSchema, 1, (issues) => {
243+
strictEqual(issues.length, 1)
244+
deepStrictEqual(issues[0].path, undefined)
245+
assertTrue(issues[0].message.includes("Service not found: Min"))
246+
})
247+
})
248+
})
249+
})

0 commit comments

Comments
 (0)