Skip to content

Commit

Permalink
Schema: Add standard API to Generate a Standard Schema
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored and tim-smart committed Feb 1, 2025
1 parent fb6f9d8 commit 68e0198
Show file tree
Hide file tree
Showing 5 changed files with 362 additions and 36 deletions.
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.23.5"
},
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
}
81 changes: 81 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 * as internalCause_ from "effect/internal/cause"
import type { ArbitraryAnnotation, ArbitraryGenerationContext, LazyArbitrary } from "./Arbitrary.js"
import * as array_ from "./Array.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 Runtime from "./Runtime.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,85 @@ const variance = {
_R: (_: never) => _
}

const makeStandardFailureResult = (message: string): StandardSchemaV1.FailureResult => ({
issues: [{ 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 validation: Effect.Effect<StandardSchemaV1.Result<A>> = decodeUnknown(value).pipe(
Effect.matchEffect({
onFailure: (issue) =>
Effect.map(ParseResult.ArrayFormatter.formatIssue(issue), (issues) => ({
issues: issues.map((issue) => ({
path: issue.path,
message: issue.message
}))
})),
onSuccess: (value) => Effect.succeed({ value })
}),
Effect.catchAllCause((cause) => Effect.succeed(makeStandardFailureResult(cause_.pretty(cause))))
)
try {
return Effect.runSync(validation)
} catch (e) {
if (Runtime.isFiberFailure(e)) {
const cause = e[Runtime.FiberFailureCauseId]
if (cause_.isDieType(cause) && Runtime.isAsyncFiberException(cause.defect)) {
// The error is caused by using runSync on an effect that performs async work.
// We can wait for the fiber to complete and return a promise
const fiber = cause.defect.fiber
return new Promise((resolve, reject) => {
fiber.addObserver((exit) => {
if (exit_.isFailure(exit)) {
reject(makeStandardFailureResult(cause_.pretty(exit.cause)))
} else {
resolve(exit.value as any)
}
})
})
}
}
return makeStandardFailureResult(`Unknown error: ${e}`)
}
}
}
}
}

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

0 comments on commit 68e0198

Please sign in to comment.