Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schema: Add standardSchemaV1 API to Generate a Standard Schema #4359

Merged
merged 3 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/twenty-owls-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"effect": minor
---

Schema: Add `standardSchemaV1` API to Generate a [Standard Schema v1](https://standardschema.dev/).

**Example**

```ts
import { Schema } from "effect"

const schema = Schema.Struct({
name: Schema.String
})

// ┌─── StandardSchemaV1<{ readonly name: string; }>
//
const standardSchema = Schema.standardSchemaV1(schema)
```
1 change: 1 addition & 0 deletions packages/effect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"zod": "^3.24.1"
},
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
}
77 changes: 77 additions & 0 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @since 3.10.0
*/

import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { ArbitraryAnnotation, ArbitraryGenerationContext, LazyArbitrary } from "./Arbitrary.js"
import * as array_ from "./Array.js"
import * as bigDecimal_ from "./BigDecimal.js"
Expand Down Expand Up @@ -44,6 +45,7 @@ import type * as pretty_ from "./Pretty.js"
import * as record_ from "./Record.js"
import * as redacted_ from "./Redacted.js"
import * as Request from "./Request.js"
import * as scheduler_ from "./Scheduler.js"
import type { ParseOptions } from "./SchemaAST.js"
import * as AST from "./SchemaAST.js"
import * as sortedSet_ from "./SortedSet.js"
Expand Down Expand Up @@ -128,6 +130,81 @@ const variance = {
_R: (_: never) => _
}

const makeStandardResult = <A>(exit: exit_.Exit<StandardSchemaV1.Result<A>>): StandardSchemaV1.Result<A> =>
exit_.isSuccess(exit) ? exit.value : makeStandardFailureResult(cause_.pretty(exit.cause))

const makeStandardFailureResult = (message: string): StandardSchemaV1.FailureResult => ({
issues: [{ message }]
})

const makeStandardFailureFromParseIssue = (
issue: ParseResult.ParseIssue
): Effect.Effect<StandardSchemaV1.FailureResult> =>
Effect.map(ParseResult.ArrayFormatter.formatIssue(issue), (issues) => ({
issues: issues.map((issue) => ({
path: issue.path,
message: issue.message
}))
}))

/**
* Returns a "Standard Schema" object conforming to the [Standard Schema
* v1](https://standardschema.dev/) specification.
*
* This function creates a schema whose `validate` method attempts to decode and
* validate the provided input synchronously. If the underlying `Schema`
* includes any asynchronous components (e.g., asynchronous message resolutions
* or checks), then validation will necessarily return a `Promise` instead.
*
* Any detected defects will be reported via a single issue containing no
* `path`.
*
* @example
* ```ts
* import { Schema } from "effect"
*
* const schema = Schema.Struct({
* name: Schema.String
* })
*
* // ┌─── StandardSchemaV1<{ readonly name: string; }>
* // ▼
* const standardSchema = Schema.standardSchemaV1(schema)
* ```
*
* @category Standard Schema
* @since 3.13.0
*/
export const standardSchemaV1 = <A, I>(schema: Schema<A, I, never>): StandardSchemaV1<I, A> => {
const decodeUnknown = ParseResult.decodeUnknown(schema)
return {
"~standard": {
version: 1,
vendor: "effect",
validate(value) {
const scheduler = new scheduler_.SyncScheduler()
const fiber = Effect.runFork(
Effect.matchEffect(decodeUnknown(value), {
onFailure: makeStandardFailureFromParseIssue,
onSuccess: (value) => Effect.succeed({ value })
}),
{ scheduler }
)
scheduler.flush()
const exit = fiber.unsafePoll()
if (exit) {
return makeStandardResult(exit)
}
return new Promise((resolve) => {
fiber.addObserver((exit) => {
resolve(makeStandardResult(exit))
})
})
}
}
}
}

interface AllAnnotations<A, TypeParameters extends ReadonlyArray<any>>
extends Annotations.Schema<A, TypeParameters>, PropertySignature.Annotations<A>
{}
Expand Down
249 changes: 249 additions & 0 deletions packages/effect/test/Schema/Schema/standardSchemaV1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Context, Effect, ParseResult, Predicate, Schema } from "effect"
import { assertTrue, deepStrictEqual, strictEqual } from "effect/test/util"
import { describe, it } from "vitest"
import { AsyncString } from "../TestUtils.js"

function validate<I, A>(
schema: StandardSchemaV1<I, A>,
input: unknown
): StandardSchemaV1.Result<A> | Promise<StandardSchemaV1.Result<A>> {
return schema["~standard"].validate(input)
}

const isPromise = (value: unknown): value is Promise<unknown> => value instanceof Promise

const expectSuccess = async <A>(
result: StandardSchemaV1.Result<A>,
a: A
) => {
deepStrictEqual(result, { value: a })
}

const expectFailure = async <A>(
result: StandardSchemaV1.Result<A>,
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
) => {
if (result.issues !== undefined) {
if (Predicate.isFunction(issues)) {
issues(result.issues)
} else {
deepStrictEqual(result.issues, issues)
}
} else {
throw new Error("Expected issues, got undefined")
}
}

const expectSyncSuccess = <I, A>(
schema: StandardSchemaV1<I, A>,
input: unknown,
a: A
) => {
const result = validate(schema, input)
if (isPromise(result)) {
throw new Error("Expected value, got promise")
} else {
expectSuccess(result, a)
}
}

const expectAsyncSuccess = async <I, A>(
schema: StandardSchemaV1<I, A>,
input: unknown,
a: A
) => {
const result = validate(schema, input)
if (isPromise(result)) {
expectSuccess(await result, a)
} else {
throw new Error("Expected promise, got value")
}
}

const expectSyncFailure = <I, A>(
schema: StandardSchemaV1<I, A>,
input: unknown,
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
) => {
const result = validate(schema, input)
if (isPromise(result)) {
throw new Error("Expected value, got promise")
} else {
expectFailure(result, issues)
}
}

const expectAsyncFailure = async <I, A>(
schema: StandardSchemaV1<I, A>,
input: unknown,
issues: ReadonlyArray<StandardSchemaV1.Issue> | ((issues: ReadonlyArray<StandardSchemaV1.Issue>) => void)
) => {
const result = validate(schema, input)
if (isPromise(result)) {
expectFailure(await result, issues)
} else {
throw new Error("Expected promise, got value")
}
}

const AsyncNonEmptyString = AsyncString.pipe(Schema.minLength(1))

describe("standardSchemaV1", () => {
it("sync decoding + sync issue formatting", () => {
const schema = Schema.NonEmptyString
const standardSchema = Schema.standardSchemaV1(schema)
expectSyncSuccess(standardSchema, "a", "a")
expectSyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
expectSyncFailure(standardSchema, "", [
{
message: `Expected a non empty string, actual ""`,
path: []
}
])
})

it("sync decoding + sync custom message", () => {
const schema = Schema.NonEmptyString.annotations({ message: () => Effect.succeed("my message") })
const standardSchema = Schema.standardSchemaV1(schema)
expectSyncSuccess(standardSchema, "a", "a")
expectSyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
expectSyncFailure(standardSchema, "", [
{
message: "my message",
path: []
}
])
})

it("sync decoding + async custom message", async () => {
const schema = Schema.NonEmptyString.annotations({
message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis"))
})
const standardSchema = Schema.standardSchemaV1(schema)
expectSyncSuccess(standardSchema, "a", "a")
await expectAsyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
await expectAsyncFailure(standardSchema, "", [
{
message: "my message",
path: []
}
])
})

it("async decoding + sync issue formatting", async () => {
const schema = AsyncNonEmptyString
const standardSchema = Schema.standardSchemaV1(schema)
await expectAsyncSuccess(standardSchema, "a", "a")
expectSyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
await expectAsyncFailure(standardSchema, "", [
{
message: `Expected a string at least 1 character(s) long, actual ""`,
path: []
}
])
})

it("async decoding + sync custom message", async () => {
const schema = AsyncNonEmptyString.annotations({ message: () => Effect.succeed("my message") })
const standardSchema = Schema.standardSchemaV1(schema)
await expectAsyncSuccess(standardSchema, "a", "a")
expectSyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
await expectAsyncFailure(standardSchema, "", [
{
message: "my message",
path: []
}
])
})

it("async decoding + async custom message", async () => {
const schema = AsyncNonEmptyString.annotations({
message: () => Effect.succeed("my message").pipe(Effect.delay("10 millis"))
})
const standardSchema = Schema.standardSchemaV1(schema)
await expectAsyncSuccess(standardSchema, "a", "a")
await expectAsyncFailure(standardSchema, null, [
{
message: "Expected string, actual null",
path: []
}
])
await expectAsyncFailure(standardSchema, "", [
{
message: "my message",
path: []
}
])
})

describe("missing dependencies", () => {
class MagicNumber extends Context.Tag("Min")<MagicNumber, number>() {}

it("sync decoding should throw", () => {
const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, {
strict: true,
decode: (n) =>
Effect.gen(function*(_) {
const magicNumber = yield* MagicNumber
return n * magicNumber
}),
encode: ParseResult.succeed
})

const schema = DepString
const standardSchema = Schema.standardSchemaV1(schema as any)
expectSyncFailure(standardSchema, 1, (issues) => {
strictEqual(issues.length, 1)
deepStrictEqual(issues[0].path, undefined)
assertTrue(issues[0].message.includes("Service not found: Min"))
})
})

it("async decoding should throw", () => {
const DepString = Schema.transformOrFail(Schema.Number, Schema.Number, {
strict: true,
decode: (n) =>
Effect.gen(function*(_) {
const magicNumber = yield* MagicNumber
yield* Effect.sleep("10 millis")
return n * magicNumber
}),
encode: ParseResult.succeed
})

const schema = DepString
const standardSchema = Schema.standardSchemaV1(schema as any)
expectSyncFailure(standardSchema, 1, (issues) => {
strictEqual(issues.length, 1)
deepStrictEqual(issues[0].path, undefined)
assertTrue(issues[0].message.includes("Service not found: Min"))
})
})
})
})
Loading