diff --git a/.changeset/twenty-owls-hunt.md b/.changeset/twenty-owls-hunt.md new file mode 100644 index 0000000000..70870ee0eb --- /dev/null +++ b/.changeset/twenty-owls-hunt.md @@ -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) +``` diff --git a/packages/effect/package.json b/packages/effect/package.json index 36f7c71afb..aadf048b64 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -50,6 +50,7 @@ "zod": "^3.24.1" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } } diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index d3562a339e..9d0403bde7 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -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" @@ -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" @@ -128,6 +130,81 @@ const variance = { _R: (_: never) => _ } +const makeStandardResult = (exit: exit_.Exit>): StandardSchemaV1.Result => + 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 => + 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 = (schema: Schema): StandardSchemaV1 => { + 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> extends Annotations.Schema, PropertySignature.Annotations {} diff --git a/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts new file mode 100644 index 0000000000..00ddec464c --- /dev/null +++ b/packages/effect/test/Schema/Schema/standardSchemaV1.test.ts @@ -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( + schema: StandardSchemaV1, + input: unknown +): StandardSchemaV1.Result | Promise> { + return schema["~standard"].validate(input) +} + +const isPromise = (value: unknown): value is Promise => value instanceof Promise + +const expectSuccess = async ( + result: StandardSchemaV1.Result, + a: A +) => { + deepStrictEqual(result, { value: a }) +} + +const expectFailure = async ( + result: StandardSchemaV1.Result, + issues: ReadonlyArray | ((issues: ReadonlyArray) => 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 = ( + schema: StandardSchemaV1, + 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 ( + schema: StandardSchemaV1, + 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 = ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => void) +) => { + const result = validate(schema, input) + if (isPromise(result)) { + throw new Error("Expected value, got promise") + } else { + expectFailure(result, issues) + } +} + +const expectAsyncFailure = async ( + schema: StandardSchemaV1, + input: unknown, + issues: ReadonlyArray | ((issues: ReadonlyArray) => 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")() {} + + 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")) + }) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5131a4692..9e5b1bfc1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,6 +308,9 @@ importers: packages/effect: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 fast-check: specifier: ^3.23.1 version: 3.23.1 @@ -3167,6 +3170,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testcontainers/mssqlserver@10.11.0': resolution: {integrity: sha512-/Rh/MEwCrsVbIQ/IJm1ScCY4dZ6wS3nOpG/xqdBTxAnQMN0pVpCIoQ1KzksKmoofP3XHDtveHrKp2cahfxsgeQ==} @@ -10931,6 +10937,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.0.0': {} + '@testcontainers/mssqlserver@10.11.0': dependencies: testcontainers: 10.11.0