From c4600ef39afcef1c3f9084bf1cc4d71a641518d2 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:16:34 -0800 Subject: [PATCH 1/7] feat(standard-schema): add Result wrappers for Standard Schema compliant libraries Add OkSchema, ErrSchema, and ResultSchema functions that wrap any Standard Schema compliant library (Zod, Valibot, ArkType, etc.) with wellcrafted's Result pattern ({ data: T, error: null } | { data: null, error: E }). - Library-agnostic: works with any StandardSchemaV1/StandardJSONSchemaV1 - Capability-preserving: output includes validate/jsonSchema only if input has them - Async-aware: preserves Promise returns from inner schemas - Path prefixing: inner validation errors prefixed with 'data' or 'error' --- bun.lock | 16 + package.json | 9 +- ...T121900-standard-schema-result-wrappers.md | 262 ++++++++++ src/standard-schema/err.ts | 180 +++++++ src/standard-schema/index.ts | 11 + src/standard-schema/ok.ts | 180 +++++++ src/standard-schema/result.ts | 283 +++++++++++ src/standard-schema/standard-schema.test.ts | 458 ++++++++++++++++++ src/standard-schema/types.ts | 230 +++++++++ tsdown.config.ts | 1 + 10 files changed, 1629 insertions(+), 1 deletion(-) create mode 100644 specs/20251231T121900-standard-schema-result-wrappers.md create mode 100644 src/standard-schema/err.ts create mode 100644 src/standard-schema/index.ts create mode 100644 src/standard-schema/ok.ts create mode 100644 src/standard-schema/result.ts create mode 100644 src/standard-schema/standard-schema.test.ts create mode 100644 src/standard-schema/types.ts diff --git a/bun.lock b/bun.lock index 886511e..590d56e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "wellcrafted", @@ -7,13 +8,20 @@ "@biomejs/biome": "^2.3.3", "@changesets/cli": "^2.27.10", "@tanstack/query-core": "^5.82.0", + "arktype": "^2.1.29", "tsdown": "^0.12.5", "typescript": "^5.8.3", + "valibot": "^1.2.0", "vitest": "^4.0.14", + "zod": "^4.3.3", }, }, }, "packages": { + "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], + + "@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], + "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "7.28.0", "@babel/types": "7.28.0", "@jridgewell/gen-mapping": "0.3.12", "@jridgewell/trace-mapping": "0.3.29", "jsesc": "3.1.0" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], @@ -268,6 +276,10 @@ "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "1.0.3" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="], + + "arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="], + "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -498,6 +510,8 @@ "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "vitest": ["vitest@4.0.15", "", { "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", "@vitest/pretty-format": "4.0.15", "@vitest/runner": "4.0.15", "@vitest/snapshot": "4.0.15", "@vitest/spy": "4.0.15", "@vitest/utils": "4.0.15", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.15", "@vitest/browser-preview": "4.0.15", "@vitest/browser-webdriverio": "4.0.15", "@vitest/ui": "4.0.15", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA=="], @@ -506,6 +520,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "zod": ["zod@4.3.3", "", {}, "sha512-bQ7Rxwfn04DCrTjjRfD9SavY2vWdmf3REjs/mkc1LdwI1KkcHClBRJmnvmA/6epGeqlHePtIRF1J4SrMMlW7IA=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], diff --git a/package.json b/package.json index a14d3ae..3983808 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "./query": { "types": "./dist/query/index.d.ts", "import": "./dist/query/index.js" + }, + "./standard-schema": { + "types": "./dist/standard-schema/index.d.ts", + "import": "./dist/standard-schema/index.js" } }, "scripts": { @@ -56,8 +60,11 @@ "@biomejs/biome": "^2.3.3", "@changesets/cli": "^2.27.10", "@tanstack/query-core": "^5.82.0", + "arktype": "^2.1.29", "tsdown": "^0.12.5", "typescript": "^5.8.3", - "vitest": "^4.0.14" + "valibot": "^1.2.0", + "vitest": "^4.0.14", + "zod": "^4.3.3" } } diff --git a/specs/20251231T121900-standard-schema-result-wrappers.md b/specs/20251231T121900-standard-schema-result-wrappers.md new file mode 100644 index 0000000..5ac0b83 --- /dev/null +++ b/specs/20251231T121900-standard-schema-result-wrappers.md @@ -0,0 +1,262 @@ +# Standard Schema Result Wrappers + +**Created**: 2025-12-31T12:19:00 +**Status**: Completed + +## Overview + +Create helper functions that wrap Standard Schema compliant schemas with wellcrafted's Result pattern (`{ data: T, error: null } | { data: null, error: E }`). + +These wrappers are library-agnostic and work with any Standard Schema compliant library (Zod, Valibot, ArkType, etc.) by operating on the `~standard` interface directly. + +## Goals + +1. **Runtime validation**: Produce actual validation logic for Ok/Err/Result shapes +2. **JSON Schema generation**: Produce JSON Schema representations when input supports it +3. **Capability-preserving**: Output schema has same capabilities as input (validation, jsonSchema, or both) +4. **Library-agnostic**: Works with any StandardSchemaV1/StandardJSONSchemaV1 compliant schema + +## API Design + +### Functions + +```typescript +// Wraps schema for T into schema for { data: T, error: null } +OkSchema(schema: TSchema): Ok + +// Wraps schema for E into schema for { data: null, error: E } +ErrSchema(schema: TSchema): Err + +// Combines data schema + error schema into discriminated union Result +ResultSchema< + TDataSchema extends StandardTypedV1, + TErrorSchema extends StandardTypedV1 +>(dataSchema: TDataSchema, errorSchema: TErrorSchema): Result +``` + +### Type Definitions + +```typescript +// Output type for OkSchema - preserves input capabilities +type Ok = { + "~standard": { + version: 1; + vendor: "wellcrafted"; + types: { + input: { data: StandardTypedV1.InferInput; error: null }; + output: { data: StandardTypedV1.InferOutput; error: null }; + }; + // validate included if TSchema has validate + // jsonSchema included if TSchema has jsonSchema + }; +}; + +// Output type for ErrSchema - preserves input capabilities +type Err = { + "~standard": { + version: 1; + vendor: "wellcrafted"; + types: { + input: { data: null; error: StandardTypedV1.InferInput }; + output: { data: null; error: StandardTypedV1.InferOutput }; + }; + }; +}; + +// Output type for ResultSchema - union of Ok and Err +type Result = { + "~standard": { + version: 1; + vendor: "wellcrafted"; + types: { + input: + | { data: StandardTypedV1.InferInput; error: null } + | { data: null; error: StandardTypedV1.InferInput }; + output: + | { data: StandardTypedV1.InferOutput; error: null } + | { data: null; error: StandardTypedV1.InferOutput }; + }; + }; +}; +``` + +## Runtime Behavior + +### OkSchema Validation + +```typescript +function validate(value: unknown) { + // 1. Check value is non-null object + if (typeof value !== 'object' || value === null) { + return { issues: [{ message: "Expected object" }] }; + } + + // 2. Check has data and error properties + if (!('data' in value) || !('error' in value)) { + return { issues: [{ message: "Expected object with 'data' and 'error' properties" }] }; + } + + // 3. Check error is null (Ok discriminant) + if (value.error !== null) { + return { issues: [{ message: "Expected 'error' to be null for Ok variant" }] }; + } + + // 4. Delegate data validation to inner schema + const innerResult = innerSchema["~standard"].validate(value.data); + + // 5. Handle sync/async, wrap result + // ... (handle Promise case) + if (innerResult.issues) { + // Prefix paths with 'data' + return { issues: innerResult.issues.map(i => ({ + ...i, + path: ['data', ...(i.path || [])] + })) }; + } + + return { value: { data: innerResult.value, error: null } }; +} +``` + +### ErrSchema Validation + +Same pattern but: +- Check `data === null` (Err discriminant) +- Validate `error` field with inner schema +- Prefix paths with 'error' + +### ResultSchema Validation + +```typescript +function validate(value: unknown) { + // 1. Check value is non-null object with data and error + // ... (same checks) + + // 2. Determine variant by discriminant + if (value.error === null) { + // Ok variant - delegate to data schema + const innerResult = dataSchema["~standard"].validate(value.data); + // ... wrap as Ok + } else if (value.data === null) { + // Err variant - delegate to error schema + const innerResult = errorSchema["~standard"].validate(value.error); + // ... wrap as Err + } else { + // Invalid - neither variant (both non-null) + return { issues: [{ message: "Invalid Result: exactly one of 'data' or 'error' must be null" }] }; + } +} +``` + +## JSON Schema Generation + +### OkSchema JSON Schema + +```json +{ + "type": "object", + "properties": { + "data": { /* inner schema's JSON Schema */ }, + "error": { "type": "null" } + }, + "required": ["data", "error"], + "additionalProperties": false +} +``` + +### ErrSchema JSON Schema + +```json +{ + "type": "object", + "properties": { + "data": { "type": "null" }, + "error": { /* inner schema's JSON Schema */ } + }, + "required": ["data", "error"], + "additionalProperties": false +} +``` + +### ResultSchema JSON Schema + +```json +{ + "oneOf": [ + { /* OkSchema JSON Schema */ }, + { /* ErrSchema JSON Schema */ } + ] +} +``` + +## File Structure + +``` +src/standard-schema/ + types.ts # StandardSchemaV1, StandardJSONSchemaV1 interfaces (copied from spec) + ok.ts # OkSchema function + Ok type + err.ts # ErrSchema function + Err type + result.ts # ResultSchema function + Result type + index.ts # Re-exports all +``` + +## Todo + +- [x] Create `src/standard-schema/types.ts` with Standard Schema interfaces +- [x] Create `src/standard-schema/ok.ts` with OkSchema function +- [x] Create `src/standard-schema/err.ts` with ErrSchema function +- [x] Create `src/standard-schema/result.ts` with ResultSchema function +- [x] Create `src/standard-schema/index.ts` with re-exports +- [x] Add tests for validation behavior +- [x] Add tests for JSON Schema generation +- [x] Update package.json exports +- [x] Update tsdown.config.ts to include new entry point + +## Open Questions + +1. Should we handle async validation (when inner schema returns Promise)? + - **Proposal**: Yes, preserve async behavior - if inner returns Promise, outer returns Promise + +2. Should `additionalProperties: false` be configurable? + - **Proposal**: Default to false for strictness, could add option later + +3. Error message format - should we namespace with "wellcrafted:" prefix? + - **Proposal**: Keep simple for now, can enhance later + +## Review + +### Implementation Summary + +Successfully implemented Standard Schema Result wrappers with the following files: + +1. **`types.ts`** (230 lines): Copied Standard Schema interfaces (StandardTypedV1, StandardSchemaV1, StandardJSONSchemaV1) plus utility functions `hasValidate()` and `hasJsonSchema()` for capability detection. + +2. **`ok.ts`** (165 lines): `OkSchema()` function that wraps a schema into `{ data: T, error: null }` structure. Includes validation logic that checks discriminant and delegates to inner schema, plus JSON Schema generation. + +3. **`err.ts`** (165 lines): `ErrSchema()` function that wraps a schema into `{ data: null, error: E }` structure. Mirror of OkSchema with inverted discriminant. + +4. **`result.ts`** (270 lines): `ResultSchema()` function that combines data and error schemas into a discriminated union. Validation determines variant by checking which field is null. + +5. **`index.ts`**: Re-exports all public API. + +6. **`standard-schema.test.ts`** (460 lines): 23 tests covering: + - OkSchema validation (valid, invalid structure, wrong variant, inner errors) + - ErrSchema validation (valid, wrong variant, inner errors) + - ResultSchema validation (Ok variant, Err variant, invalid states) + - JSON Schema generation for all three + - Capability preservation (validate-only schemas don't get jsonSchema) + - Type inference verification + +### Key Design Decisions + +1. **Capability preservation**: Output schema only includes `validate` if input has it; same for `jsonSchema`. This allows wrapping validation-only schemas without runtime errors. + +2. **Path prefixing**: Inner schema validation errors get their paths prefixed with `data` or `error` to indicate location in the Result structure. + +3. **Async support**: When inner schema's validate returns a Promise, the wrapper preserves this and returns a Promise as well. + +4. **Edge case handling**: `{ data: null, error: null }` is treated as valid (Ok with null data value), matching wellcrafted's existing Result semantics. + +### Test Results + +All 100 tests pass (23 new + 77 existing). diff --git a/src/standard-schema/err.ts b/src/standard-schema/err.ts new file mode 100644 index 0000000..96efeda --- /dev/null +++ b/src/standard-schema/err.ts @@ -0,0 +1,180 @@ +import { + hasJsonSchema, + hasValidate, + type StandardJSONSchemaV1, + type StandardSchemaV1, + type StandardTypedV1, +} from "./types.js"; + +/** + * Output type for ErrSchema - wraps inner schema's types with Err structure. + * + * Preserves the capabilities of the input schema: + * - If input has validate, output has validate + * - If input has jsonSchema, output has jsonSchema + */ +export type Err = { + readonly "~standard": { + readonly version: 1; + readonly vendor: "wellcrafted"; + readonly types: { + readonly input: { + data: null; + error: StandardTypedV1.InferInput; + }; + readonly output: { + data: null; + error: StandardTypedV1.InferOutput; + }; + }; + } & (TSchema extends StandardSchemaV1 + ? { + readonly validate: StandardSchemaV1.Props< + { data: null; error: StandardTypedV1.InferInput }, + { data: null; error: StandardTypedV1.InferOutput } + >["validate"]; + } + : Record) & + (TSchema extends StandardJSONSchemaV1 + ? { readonly jsonSchema: StandardJSONSchemaV1.Converter } + : Record); +}; + +function createErrValidate( + innerSchema: TSchema, +): StandardSchemaV1.Props< + { data: null; error: StandardTypedV1.InferInput }, + { data: null; error: StandardTypedV1.InferOutput } +>["validate"] { + return (value: unknown) => { + if (typeof value !== "object" || value === null) { + return { issues: [{ message: "Expected object" }] }; + } + + if (!("data" in value) || !("error" in value)) { + return { + issues: [ + { message: "Expected object with 'data' and 'error' properties" }, + ], + }; + } + + const obj = value as { data: unknown; error: unknown }; + + if (obj.data !== null) { + return { + issues: [{ message: "Expected 'data' to be null for Err variant" }], + }; + } + + const innerResult = innerSchema["~standard"].validate(obj.error); + + if (innerResult instanceof Promise) { + return innerResult.then((r) => { + if (r.issues) { + return { + issues: r.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["error", ...(issue.path || [])], + })), + }; + } + return { value: { data: null as null, error: r.value } }; + }); + } + + if (innerResult.issues) { + return { + issues: innerResult.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["error", ...(issue.path || [])], + })), + }; + } + + return { value: { data: null as null, error: innerResult.value } }; + }; +} + +function createErrJsonSchema( + innerSchema: TSchema, +): StandardJSONSchemaV1.Converter { + return { + input(options: StandardJSONSchemaV1.Options) { + return { + type: "object", + properties: { + data: { type: "null" }, + error: innerSchema["~standard"].jsonSchema.input(options), + }, + required: ["data", "error"], + additionalProperties: false, + }; + }, + output(options: StandardJSONSchemaV1.Options) { + return { + type: "object", + properties: { + data: { type: "null" }, + error: innerSchema["~standard"].jsonSchema.output(options), + }, + required: ["data", "error"], + additionalProperties: false, + }; + }, + }; +} + +/** + * Wraps a Standard Schema into an Err variant schema. + * + * Takes a schema for type E and returns a schema for `{ data: null, error: E }`. + * Preserves the capabilities of the input schema (validate, jsonSchema, or both). + * + * @example + * ```typescript + * import { z } from "zod"; + * import { ErrSchema } from "wellcrafted/standard-schema"; + * + * const errorSchema = z.object({ code: z.string(), message: z.string() }); + * const errResultSchema = ErrSchema(errorSchema); + * + * // Validates: { data: null, error: { code: "NOT_FOUND", message: "User not found" } } + * const result = errResultSchema["~standard"].validate({ + * data: null, + * error: { code: "NOT_FOUND", message: "User not found" }, + * }); + * ``` + */ +export function ErrSchema( + innerSchema: TSchema, +): Err { + const base = { + "~standard": { + version: 1 as const, + vendor: "wellcrafted", + types: { + input: undefined as unknown as { + data: null; + error: StandardTypedV1.InferInput; + }, + output: undefined as unknown as { + data: null; + error: StandardTypedV1.InferOutput; + }, + }, + }, + }; + + if (hasValidate(innerSchema)) { + (base["~standard"] as Record).validate = + createErrValidate(innerSchema); + } + + if (hasJsonSchema(innerSchema)) { + (base["~standard"] as Record).jsonSchema = + createErrJsonSchema(innerSchema); + } + + return base as Err; +} diff --git a/src/standard-schema/index.ts b/src/standard-schema/index.ts new file mode 100644 index 0000000..b5ba9a8 --- /dev/null +++ b/src/standard-schema/index.ts @@ -0,0 +1,11 @@ +export { ErrSchema, type Err } from "./err.js"; +export { OkSchema, type Ok } from "./ok.js"; +export { ResultSchema, type Result } from "./result.js"; +export { + hasJsonSchema, + hasValidate, + type StandardFullSchemaV1, + type StandardJSONSchemaV1, + type StandardSchemaV1, + type StandardTypedV1, +} from "./types.js"; diff --git a/src/standard-schema/ok.ts b/src/standard-schema/ok.ts new file mode 100644 index 0000000..372d6df --- /dev/null +++ b/src/standard-schema/ok.ts @@ -0,0 +1,180 @@ +import { + hasJsonSchema, + hasValidate, + type StandardJSONSchemaV1, + type StandardSchemaV1, + type StandardTypedV1, +} from "./types.js"; + +/** + * Output type for OkSchema - wraps inner schema's types with Ok structure. + * + * Preserves the capabilities of the input schema: + * - If input has validate, output has validate + * - If input has jsonSchema, output has jsonSchema + */ +export type Ok = { + readonly "~standard": { + readonly version: 1; + readonly vendor: "wellcrafted"; + readonly types: { + readonly input: { + data: StandardTypedV1.InferInput; + error: null; + }; + readonly output: { + data: StandardTypedV1.InferOutput; + error: null; + }; + }; + } & (TSchema extends StandardSchemaV1 + ? { + readonly validate: StandardSchemaV1.Props< + { data: StandardTypedV1.InferInput; error: null }, + { data: StandardTypedV1.InferOutput; error: null } + >["validate"]; + } + : Record) & + (TSchema extends StandardJSONSchemaV1 + ? { readonly jsonSchema: StandardJSONSchemaV1.Converter } + : Record); +}; + +function createOkValidate( + innerSchema: TSchema, +): StandardSchemaV1.Props< + { data: StandardTypedV1.InferInput; error: null }, + { data: StandardTypedV1.InferOutput; error: null } +>["validate"] { + return (value: unknown) => { + if (typeof value !== "object" || value === null) { + return { issues: [{ message: "Expected object" }] }; + } + + if (!("data" in value) || !("error" in value)) { + return { + issues: [ + { message: "Expected object with 'data' and 'error' properties" }, + ], + }; + } + + const obj = value as { data: unknown; error: unknown }; + + if (obj.error !== null) { + return { + issues: [{ message: "Expected 'error' to be null for Ok variant" }], + }; + } + + const innerResult = innerSchema["~standard"].validate(obj.data); + + if (innerResult instanceof Promise) { + return innerResult.then((r) => { + if (r.issues) { + return { + issues: r.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["data", ...(issue.path || [])], + })), + }; + } + return { value: { data: r.value, error: null as null } }; + }); + } + + if (innerResult.issues) { + return { + issues: innerResult.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["data", ...(issue.path || [])], + })), + }; + } + + return { value: { data: innerResult.value, error: null as null } }; + }; +} + +function createOkJsonSchema( + innerSchema: TSchema, +): StandardJSONSchemaV1.Converter { + return { + input(options: StandardJSONSchemaV1.Options) { + return { + type: "object", + properties: { + data: innerSchema["~standard"].jsonSchema.input(options), + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }; + }, + output(options: StandardJSONSchemaV1.Options) { + return { + type: "object", + properties: { + data: innerSchema["~standard"].jsonSchema.output(options), + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }; + }, + }; +} + +/** + * Wraps a Standard Schema into an Ok variant schema. + * + * Takes a schema for type T and returns a schema for `{ data: T, error: null }`. + * Preserves the capabilities of the input schema (validate, jsonSchema, or both). + * + * @example + * ```typescript + * import { z } from "zod"; + * import { OkSchema } from "wellcrafted/standard-schema"; + * + * const userSchema = z.object({ name: z.string() }); + * const okUserSchema = OkSchema(userSchema); + * + * // Validates: { data: { name: "Alice" }, error: null } + * const result = okUserSchema["~standard"].validate({ + * data: { name: "Alice" }, + * error: null, + * }); + * ``` + */ +export function OkSchema( + innerSchema: TSchema, +): Ok { + const base = { + "~standard": { + version: 1 as const, + vendor: "wellcrafted", + types: { + input: undefined as unknown as { + data: StandardTypedV1.InferInput; + error: null; + }, + output: undefined as unknown as { + data: StandardTypedV1.InferOutput; + error: null; + }, + }, + }, + }; + + if (hasValidate(innerSchema)) { + (base["~standard"] as Record).validate = + createOkValidate(innerSchema); + } + + if (hasJsonSchema(innerSchema)) { + (base["~standard"] as Record).jsonSchema = + createOkJsonSchema(innerSchema); + } + + return base as Ok; +} diff --git a/src/standard-schema/result.ts b/src/standard-schema/result.ts new file mode 100644 index 0000000..465cedd --- /dev/null +++ b/src/standard-schema/result.ts @@ -0,0 +1,283 @@ +import { + hasJsonSchema, + hasValidate, + type StandardJSONSchemaV1, + type StandardSchemaV1, + type StandardTypedV1, +} from "./types.js"; + +/** + * Output type for ResultSchema - creates a discriminated union of Ok and Err. + * + * Preserves the capabilities of the input schemas: + * - If both inputs have validate, output has validate + * - If both inputs have jsonSchema, output has jsonSchema + */ +export type Result< + TDataSchema extends StandardTypedV1, + TErrorSchema extends StandardTypedV1, +> = { + readonly "~standard": { + readonly version: 1; + readonly vendor: "wellcrafted"; + readonly types: { + readonly input: + | { data: StandardTypedV1.InferInput; error: null } + | { data: null; error: StandardTypedV1.InferInput }; + readonly output: + | { data: StandardTypedV1.InferOutput; error: null } + | { data: null; error: StandardTypedV1.InferOutput }; + }; + } & (TDataSchema extends StandardSchemaV1 + ? TErrorSchema extends StandardSchemaV1 + ? { + readonly validate: StandardSchemaV1.Props< + | { + data: StandardTypedV1.InferInput; + error: null; + } + | { + data: null; + error: StandardTypedV1.InferInput; + }, + | { + data: StandardTypedV1.InferOutput; + error: null; + } + | { + data: null; + error: StandardTypedV1.InferOutput; + } + >["validate"]; + } + : Record + : Record) & + (TDataSchema extends StandardJSONSchemaV1 + ? TErrorSchema extends StandardJSONSchemaV1 + ? { readonly jsonSchema: StandardJSONSchemaV1.Converter } + : Record + : Record); +}; + +function createResultValidate< + TDataSchema extends StandardSchemaV1, + TErrorSchema extends StandardSchemaV1, +>( + dataSchema: TDataSchema, + errorSchema: TErrorSchema, +): StandardSchemaV1.Props< + | { data: StandardTypedV1.InferInput; error: null } + | { data: null; error: StandardTypedV1.InferInput }, + | { data: StandardTypedV1.InferOutput; error: null } + | { data: null; error: StandardTypedV1.InferOutput } +>["validate"] { + return (value: unknown) => { + if (typeof value !== "object" || value === null) { + return { issues: [{ message: "Expected object" }] }; + } + + if (!("data" in value) || !("error" in value)) { + return { + issues: [ + { message: "Expected object with 'data' and 'error' properties" }, + ], + }; + } + + const obj = value as { data: unknown; error: unknown }; + + const isOkVariant = obj.error === null; + const isErrVariant = obj.data === null; + + if (isOkVariant && isErrVariant) { + return { value: { data: null as null, error: null as null } as never }; + } + + if (!isOkVariant && !isErrVariant) { + return { + issues: [ + { + message: + "Invalid Result: exactly one of 'data' or 'error' must be null", + }, + ], + }; + } + + if (isOkVariant) { + const innerResult = dataSchema["~standard"].validate(obj.data); + + if (innerResult instanceof Promise) { + return innerResult.then((r) => { + if (r.issues) { + return { + issues: r.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["data", ...(issue.path || [])], + })), + }; + } + return { value: { data: r.value, error: null as null } }; + }); + } + + if (innerResult.issues) { + return { + issues: innerResult.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["data", ...(issue.path || [])], + })), + }; + } + + return { value: { data: innerResult.value, error: null as null } }; + } + + const innerResult = errorSchema["~standard"].validate(obj.error); + + if (innerResult instanceof Promise) { + return innerResult.then((r) => { + if (r.issues) { + return { + issues: r.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["error", ...(issue.path || [])], + })), + }; + } + return { value: { data: null as null, error: r.value } }; + }); + } + + if (innerResult.issues) { + return { + issues: innerResult.issues.map((issue: StandardSchemaV1.Issue) => ({ + ...issue, + path: ["error", ...(issue.path || [])], + })), + }; + } + + return { value: { data: null as null, error: innerResult.value } }; + }; +} + +function createResultJsonSchema< + TDataSchema extends StandardJSONSchemaV1, + TErrorSchema extends StandardJSONSchemaV1, +>( + dataSchema: TDataSchema, + errorSchema: TErrorSchema, +): StandardJSONSchemaV1.Converter { + return { + input(options: StandardJSONSchemaV1.Options) { + return { + oneOf: [ + { + type: "object", + properties: { + data: dataSchema["~standard"].jsonSchema.input(options), + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }, + { + type: "object", + properties: { + data: { type: "null" }, + error: errorSchema["~standard"].jsonSchema.input(options), + }, + required: ["data", "error"], + additionalProperties: false, + }, + ], + }; + }, + output(options: StandardJSONSchemaV1.Options) { + return { + oneOf: [ + { + type: "object", + properties: { + data: dataSchema["~standard"].jsonSchema.output(options), + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }, + { + type: "object", + properties: { + data: { type: "null" }, + error: errorSchema["~standard"].jsonSchema.output(options), + }, + required: ["data", "error"], + additionalProperties: false, + }, + ], + }; + }, + }; +} + +/** + * Combines two Standard Schemas into a Result discriminated union schema. + * + * Takes a data schema for type T and an error schema for type E, returning a schema + * for `{ data: T, error: null } | { data: null, error: E }`. + * + * Preserves the capabilities of the input schemas - if both have validate, output + * has validate; if both have jsonSchema, output has jsonSchema. + * + * @example + * ```typescript + * import { z } from "zod"; + * import { ResultSchema } from "wellcrafted/standard-schema"; + * + * const userSchema = z.object({ id: z.string(), name: z.string() }); + * const errorSchema = z.object({ code: z.string(), message: z.string() }); + * const resultSchema = ResultSchema(userSchema, errorSchema); + * + * // Validates Ok variant: { data: { id: "1", name: "Alice" }, error: null } + * // Validates Err variant: { data: null, error: { code: "NOT_FOUND", message: "..." } } + * const result = resultSchema["~standard"].validate({ + * data: { id: "1", name: "Alice" }, + * error: null, + * }); + * ``` + */ +export function ResultSchema< + TDataSchema extends StandardTypedV1, + TErrorSchema extends StandardTypedV1, +>( + dataSchema: TDataSchema, + errorSchema: TErrorSchema, +): Result { + const base = { + "~standard": { + version: 1 as const, + vendor: "wellcrafted", + types: { + input: undefined as unknown as + | { data: StandardTypedV1.InferInput; error: null } + | { data: null; error: StandardTypedV1.InferInput }, + output: undefined as unknown as + | { data: StandardTypedV1.InferOutput; error: null } + | { data: null; error: StandardTypedV1.InferOutput }, + }, + }, + }; + + if (hasValidate(dataSchema) && hasValidate(errorSchema)) { + (base["~standard"] as Record).validate = + createResultValidate(dataSchema, errorSchema); + } + + if (hasJsonSchema(dataSchema) && hasJsonSchema(errorSchema)) { + (base["~standard"] as Record).jsonSchema = + createResultJsonSchema(dataSchema, errorSchema); + } + + return base as Result; +} diff --git a/src/standard-schema/standard-schema.test.ts b/src/standard-schema/standard-schema.test.ts new file mode 100644 index 0000000..deebb7e --- /dev/null +++ b/src/standard-schema/standard-schema.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from "vitest"; +import { ErrSchema, OkSchema, ResultSchema } from "./index.js"; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "./types.js"; + +function createMockStringSchema(): StandardSchemaV1 & + StandardJSONSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "test", + types: { + input: "" as string, + output: "" as string, + }, + validate(value: unknown) { + if (typeof value !== "string") { + return { issues: [{ message: "Expected string" }] }; + } + return { value }; + }, + jsonSchema: { + input: () => ({ type: "string" }), + output: () => ({ type: "string" }), + }, + }, + }; +} + +function createMockObjectSchema(): StandardSchemaV1< + { name: string }, + { name: string } +> & + StandardJSONSchemaV1<{ name: string }, { name: string }> { + return { + "~standard": { + version: 1, + vendor: "test", + types: { + input: { name: "" } as { name: string }, + output: { name: "" } as { name: string }, + }, + validate(value: unknown) { + if ( + typeof value !== "object" || + value === null || + !("name" in value) || + typeof (value as { name: unknown }).name !== "string" + ) { + return { issues: [{ message: "Expected object with name string" }] }; + } + return { value: value as { name: string } }; + }, + jsonSchema: { + input: () => ({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }), + output: () => ({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }), + }, + }, + }; +} + +function createValidationOnlySchema(): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "test", + types: { + input: "" as string, + output: "" as string, + }, + validate(value: unknown) { + if (typeof value !== "string") { + return { issues: [{ message: "Expected string" }] }; + } + return { value }; + }, + }, + }; +} + +describe("OkSchema", () => { + describe("validation", () => { + it("validates Ok variant successfully", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const result = okSchema["~standard"].validate({ + data: "hello", + error: null, + }); + + expect(result).toEqual({ value: { data: "hello", error: null } }); + }); + + it("rejects non-object values", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const result = okSchema["~standard"].validate("not an object"); + + expect(result).toEqual({ issues: [{ message: "Expected object" }] }); + }); + + it("rejects objects without data/error properties", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const result = okSchema["~standard"].validate({ foo: "bar" }); + + expect(result).toEqual({ + issues: [ + { message: "Expected object with 'data' and 'error' properties" }, + ], + }); + }); + + it("rejects Err variant (error not null)", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const result = okSchema["~standard"].validate({ + data: null, + error: "some error", + }); + + expect(result).toEqual({ + issues: [{ message: "Expected 'error' to be null for Ok variant" }], + }); + }); + + it("propagates inner schema validation errors with path prefix", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const result = okSchema["~standard"].validate({ + data: 123, + error: null, + }); + + expect(result).toEqual({ + issues: [{ message: "Expected string", path: ["data"] }], + }); + }); + + it("validates nested objects and prefixes paths", () => { + const objectSchema = createMockObjectSchema(); + const okSchema = OkSchema(objectSchema); + + const result = okSchema["~standard"].validate({ + data: { name: "Alice" }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { name: "Alice" }, error: null }, + }); + }); + }); + + describe("jsonSchema", () => { + it("generates JSON Schema for Ok variant", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + const inputSchema = okSchema["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }); + + expect(inputSchema).toEqual({ + type: "object", + properties: { + data: { type: "string" }, + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }); + }); + }); + + describe("capability preservation", () => { + it("includes validate when input has validate", () => { + const validationOnly = createValidationOnlySchema(); + const okSchema = OkSchema(validationOnly); + + expect(okSchema["~standard"].validate).toBeDefined(); + }); + + it("does not include jsonSchema when input lacks it", () => { + const validationOnly = createValidationOnlySchema(); + const okSchema = OkSchema(validationOnly); + + expect( + (okSchema["~standard"] as Record).jsonSchema, + ).toBeUndefined(); + }); + }); +}); + +describe("ErrSchema", () => { + describe("validation", () => { + it("validates Err variant successfully", () => { + const stringSchema = createMockStringSchema(); + const errSchema = ErrSchema(stringSchema); + + const result = errSchema["~standard"].validate({ + data: null, + error: "error message", + }); + + expect(result).toEqual({ + value: { data: null, error: "error message" }, + }); + }); + + it("rejects Ok variant (data not null)", () => { + const stringSchema = createMockStringSchema(); + const errSchema = ErrSchema(stringSchema); + + const result = errSchema["~standard"].validate({ + data: "some data", + error: null, + }); + + expect(result).toEqual({ + issues: [{ message: "Expected 'data' to be null for Err variant" }], + }); + }); + + it("propagates inner schema validation errors with path prefix", () => { + const stringSchema = createMockStringSchema(); + const errSchema = ErrSchema(stringSchema); + + const result = errSchema["~standard"].validate({ + data: null, + error: 123, + }); + + expect(result).toEqual({ + issues: [{ message: "Expected string", path: ["error"] }], + }); + }); + }); + + describe("jsonSchema", () => { + it("generates JSON Schema for Err variant", () => { + const stringSchema = createMockStringSchema(); + const errSchema = ErrSchema(stringSchema); + + const inputSchema = errSchema["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }); + + expect(inputSchema).toEqual({ + type: "object", + properties: { + data: { type: "null" }, + error: { type: "string" }, + }, + required: ["data", "error"], + additionalProperties: false, + }); + }); + }); +}); + +describe("ResultSchema", () => { + describe("validation", () => { + it("validates Ok variant successfully", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: { name: "Alice" }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { name: "Alice" }, error: null }, + }); + }); + + it("validates Err variant successfully", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: null, + error: "something went wrong", + }); + + expect(result).toEqual({ + value: { data: null, error: "something went wrong" }, + }); + }); + + it("rejects invalid Result (neither null)", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: { name: "Alice" }, + error: "oops", + }); + + expect(result).toEqual({ + issues: [ + { + message: + "Invalid Result: exactly one of 'data' or 'error' must be null", + }, + ], + }); + }); + + it("propagates data schema errors with path prefix", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: { invalid: true }, + error: null, + }); + + expect(result).toEqual({ + issues: [ + { message: "Expected object with name string", path: ["data"] }, + ], + }); + }); + + it("propagates error schema errors with path prefix", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: null, + error: 123, + }); + + expect(result).toEqual({ + issues: [{ message: "Expected string", path: ["error"] }], + }); + }); + }); + + describe("jsonSchema", () => { + it("generates JSON Schema with oneOf for discriminated union", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const inputSchema = resultSchema["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }); + + expect(inputSchema).toEqual({ + oneOf: [ + { + type: "object", + properties: { + data: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + error: { type: "null" }, + }, + required: ["data", "error"], + additionalProperties: false, + }, + { + type: "object", + properties: { + data: { type: "null" }, + error: { type: "string" }, + }, + required: ["data", "error"], + additionalProperties: false, + }, + ], + }); + }); + }); + + describe("edge cases", () => { + it("handles both data and error being null", () => { + const dataSchema = createMockStringSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: null, + error: null, + }); + + expect(result).toEqual({ + value: { data: null, error: null }, + }); + }); + }); +}); + +describe("type inference", () => { + it("OkSchema infers correct types", () => { + const stringSchema = createMockStringSchema(); + const okSchema = OkSchema(stringSchema); + + type Input = (typeof okSchema)["~standard"]["types"]["input"]; + type Output = (typeof okSchema)["~standard"]["types"]["output"]; + + const _inputCheck: Input = { data: "hello", error: null }; + const _outputCheck: Output = { data: "world", error: null }; + + expect(true).toBe(true); + }); + + it("ErrSchema infers correct types", () => { + const stringSchema = createMockStringSchema(); + const errSchema = ErrSchema(stringSchema); + + type Input = (typeof errSchema)["~standard"]["types"]["input"]; + type Output = (typeof errSchema)["~standard"]["types"]["output"]; + + const _inputCheck: Input = { data: null, error: "error" }; + const _outputCheck: Output = { data: null, error: "error" }; + + expect(true).toBe(true); + }); + + it("ResultSchema infers correct union types", () => { + const dataSchema = createMockObjectSchema(); + const errorSchema = createMockStringSchema(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + type Input = (typeof resultSchema)["~standard"]["types"]["input"]; + type Output = (typeof resultSchema)["~standard"]["types"]["output"]; + + const _inputOk: Input = { data: { name: "Alice" }, error: null }; + const _inputErr: Input = { data: null, error: "oops" }; + const _outputOk: Output = { data: { name: "Bob" }, error: null }; + const _outputErr: Output = { data: null, error: "error" }; + + expect(true).toBe(true); + }); +}); diff --git a/src/standard-schema/types.ts b/src/standard-schema/types.ts new file mode 100644 index 0000000..def08cc --- /dev/null +++ b/src/standard-schema/types.ts @@ -0,0 +1,230 @@ +/** + * Standard Schema type definitions. + * + * These interfaces are copied from the Standard Schema specification + * (https://standardschema.dev) to avoid external dependencies. + * + * @see https://github.com/standard-schema/standard-schema + */ + +// ######################### +// ### Standard Typed ### +// ######################### + +/** + * The Standard Typed interface. This is a base type extended by other specs. + */ +export type StandardTypedV1 = { + /** The Standard properties. */ + readonly "~standard": StandardTypedV1.Props; +}; + +export declare namespace StandardTypedV1 { + /** The Standard Typed properties interface. */ + type Props = { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + }; + + /** The Standard Typed types interface. */ + type Types = { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + }; + + /** Infers the input type of a Standard Typed. */ + type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Typed. */ + type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} + +// ########################## +// ### Standard Schema ### +// ########################## + +/** + * The Standard Schema interface. + * + * Extends StandardTypedV1 with a validate function for runtime validation. + */ +export type StandardSchemaV1 = { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +}; + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + type Props = StandardTypedV1.Props< + Input, + Output + > & { + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined, + ) => Result | Promise>; + }; + + /** The result interface of the validate function. */ + type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + type SuccessResult = { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + }; + + /** Options for the validate function. */ + type Options = { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + }; + + /** The result interface if validation fails. */ + type FailureResult = { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + }; + + /** The issue interface of the failure output. */ + type Issue = { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + }; + + /** The path segment interface of the issue. */ + type PathSegment = { + /** The key representing a path segment. */ + readonly key: PropertyKey; + }; + + /** Infers the input type of a Standard Schema. */ + type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard Schema. */ + type InferOutput = + StandardTypedV1.InferOutput; +} + +// ############################### +// ### Standard JSON Schema ### +// ############################### + +/** + * The Standard JSON Schema interface. + * + * Extends StandardTypedV1 with methods for generating JSON Schema. + */ +export type StandardJSONSchemaV1 = { + /** The Standard JSON Schema properties. */ + readonly "~standard": StandardJSONSchemaV1.Props; +}; + +export declare namespace StandardJSONSchemaV1 { + /** The Standard JSON Schema properties interface. */ + type Props = StandardTypedV1.Props< + Input, + Output + > & { + /** Methods for generating the input/output JSON Schema. */ + readonly jsonSchema: StandardJSONSchemaV1.Converter; + }; + + /** The Standard JSON Schema converter interface. */ + type Converter = { + /** Converts the input type to JSON Schema. May throw if conversion is not supported. */ + readonly input: ( + options: StandardJSONSchemaV1.Options, + ) => Record; + /** Converts the output type to JSON Schema. May throw if conversion is not supported. */ + readonly output: ( + options: StandardJSONSchemaV1.Options, + ) => Record; + }; + + /** + * The target version of the generated JSON Schema. + * + * It is *strongly recommended* that implementers support `"draft-2020-12"` and `"draft-07"`, + * as they are both in wide use. All other targets can be implemented on a best-effort basis. + * Libraries should throw if they don't support a specified target. + * + * The `"openapi-3.0"` target is intended as a standardized specifier for OpenAPI 3.0 + * which is a superset of JSON Schema `"draft-04"`. + */ + type Target = + | "draft-2020-12" + | "draft-07" + | "openapi-3.0" + // Accepts any string for future targets while preserving autocomplete + | (string & {}); + + /** The options for the input/output methods. */ + type Options = { + /** Specifies the target version of the generated JSON Schema. */ + readonly target: Target; + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + }; + + /** Infers the input type of a Standard JSON Schema. */ + type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard JSON Schema. */ + type InferOutput = + StandardTypedV1.InferOutput; +} + +// ############################### +// ### Utility Types ### +// ############################### + +/** + * A schema that implements both StandardSchemaV1 and StandardJSONSchemaV1. + */ +export type StandardFullSchemaV1 = { + readonly "~standard": StandardSchemaV1.Props & + StandardJSONSchemaV1.Props; +}; + +/** + * Checks if a schema has validation capability. + */ +export function hasValidate( + schema: T, +): schema is T & StandardSchemaV1 { + return ( + "validate" in schema["~standard"] && + typeof schema["~standard"].validate === "function" + ); +} + +/** + * Checks if a schema has JSON Schema generation capability. + */ +export function hasJsonSchema( + schema: T, +): schema is T & StandardJSONSchemaV1 { + return ( + "jsonSchema" in schema["~standard"] && + typeof schema["~standard"].jsonSchema === "object" && + schema["~standard"].jsonSchema !== null + ); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 89de281..8ae081a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "src/error/index.ts", "src/brand.ts", "src/query/index.ts", + "src/standard-schema/index.ts", ], format: ["esm"], target: "esnext", From b60fc0e5211659df61cbe8a86141971746b78c33 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:18:25 -0800 Subject: [PATCH 2/7] test(standard-schema): use real Zod, Valibot, ArkType instead of mocks Replace mock schema implementations with actual Standard Schema compliant libraries for more realistic and comprehensive testing. Tests now cover: - All three major libraries (Zod, Valibot, ArkType) for each wrapper - Complex nested objects and validation error path propagation - Mixed library usage (e.g., Zod data schema + Valibot error schema) - Edge cases like null values, empty objects, deeply nested errors --- src/standard-schema/standard-schema.test.ts | 553 +++++++++++--------- 1 file changed, 308 insertions(+), 245 deletions(-) diff --git a/src/standard-schema/standard-schema.test.ts b/src/standard-schema/standard-schema.test.ts index deebb7e..fa538eb 100644 --- a/src/standard-schema/standard-schema.test.ts +++ b/src/standard-schema/standard-schema.test.ts @@ -1,96 +1,15 @@ +import { type } from "arktype"; +import * as v from "valibot"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { ErrSchema, OkSchema, ResultSchema } from "./index.js"; -import type { StandardJSONSchemaV1, StandardSchemaV1 } from "./types.js"; - -function createMockStringSchema(): StandardSchemaV1 & - StandardJSONSchemaV1 { - return { - "~standard": { - version: 1, - vendor: "test", - types: { - input: "" as string, - output: "" as string, - }, - validate(value: unknown) { - if (typeof value !== "string") { - return { issues: [{ message: "Expected string" }] }; - } - return { value }; - }, - jsonSchema: { - input: () => ({ type: "string" }), - output: () => ({ type: "string" }), - }, - }, - }; -} - -function createMockObjectSchema(): StandardSchemaV1< - { name: string }, - { name: string } -> & - StandardJSONSchemaV1<{ name: string }, { name: string }> { - return { - "~standard": { - version: 1, - vendor: "test", - types: { - input: { name: "" } as { name: string }, - output: { name: "" } as { name: string }, - }, - validate(value: unknown) { - if ( - typeof value !== "object" || - value === null || - !("name" in value) || - typeof (value as { name: unknown }).name !== "string" - ) { - return { issues: [{ message: "Expected object with name string" }] }; - } - return { value: value as { name: string } }; - }, - jsonSchema: { - input: () => ({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }), - output: () => ({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }), - }, - }, - }; -} - -function createValidationOnlySchema(): StandardSchemaV1 { - return { - "~standard": { - version: 1, - vendor: "test", - types: { - input: "" as string, - output: "" as string, - }, - validate(value: unknown) { - if (typeof value !== "string") { - return { issues: [{ message: "Expected string" }] }; - } - return { value }; - }, - }, - }; -} describe("OkSchema", () => { - describe("validation", () => { - it("validates Ok variant successfully", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); + describe("with Zod", () => { + const stringSchema = z.string(); + const okSchema = OkSchema(stringSchema); + it("validates Ok variant successfully", () => { const result = okSchema["~standard"].validate({ data: "hello", error: null, @@ -100,18 +19,12 @@ describe("OkSchema", () => { }); it("rejects non-object values", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); - const result = okSchema["~standard"].validate("not an object"); expect(result).toEqual({ issues: [{ message: "Expected object" }] }); }); it("rejects objects without data/error properties", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); - const result = okSchema["~standard"].validate({ foo: "bar" }); expect(result).toEqual({ @@ -122,9 +35,6 @@ describe("OkSchema", () => { }); it("rejects Err variant (error not null)", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); - const result = okSchema["~standard"].validate({ data: null, error: "some error", @@ -136,94 +46,140 @@ describe("OkSchema", () => { }); it("propagates inner schema validation errors with path prefix", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); - const result = okSchema["~standard"].validate({ data: 123, error: null, }); + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("data"); + }); + + it("validates complex objects", () => { + const userSchema = z.object({ id: z.string(), name: z.string() }); + const okUserSchema = OkSchema(userSchema); + + const result = okUserSchema["~standard"].validate({ + data: { id: "123", name: "Alice" }, + error: null, + }); + expect(result).toEqual({ - issues: [{ message: "Expected string", path: ["data"] }], + value: { data: { id: "123", name: "Alice" }, error: null }, }); }); + }); - it("validates nested objects and prefixes paths", () => { - const objectSchema = createMockObjectSchema(); - const okSchema = OkSchema(objectSchema); + describe("with Valibot", () => { + const stringSchema = v.string(); + const okSchema = OkSchema(stringSchema); + it("validates Ok variant successfully", () => { const result = okSchema["~standard"].validate({ - data: { name: "Alice" }, + data: "hello from valibot", error: null, }); expect(result).toEqual({ - value: { data: { name: "Alice" }, error: null }, + value: { data: "hello from valibot", error: null }, }); }); - }); - describe("jsonSchema", () => { - it("generates JSON Schema for Ok variant", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); + it("propagates validation errors with path prefix", () => { + const result = okSchema["~standard"].validate({ + data: 42, + error: null, + }); - const inputSchema = okSchema["~standard"].jsonSchema.input({ - target: "draft-2020-12", + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("data"); + }); + + it("validates complex objects", () => { + const userSchema = v.object({ + id: v.string(), + email: v.pipe(v.string(), v.email()), }); + const okUserSchema = OkSchema(userSchema); - expect(inputSchema).toEqual({ - type: "object", - properties: { - data: { type: "string" }, - error: { type: "null" }, - }, - required: ["data", "error"], - additionalProperties: false, + const result = okUserSchema["~standard"].validate({ + data: { id: "123", email: "alice@example.com" }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { id: "123", email: "alice@example.com" }, error: null }, }); }); }); - describe("capability preservation", () => { - it("includes validate when input has validate", () => { - const validationOnly = createValidationOnlySchema(); - const okSchema = OkSchema(validationOnly); + describe("with ArkType", () => { + const stringSchema = type("string"); + const okSchema = OkSchema(stringSchema); + + it("validates Ok variant successfully", () => { + const result = okSchema["~standard"].validate({ + data: "hello from arktype", + error: null, + }); + + expect(result).toEqual({ + value: { data: "hello from arktype", error: null }, + }); + }); + + it("propagates validation errors with path prefix", () => { + const result = okSchema["~standard"].validate({ + data: 999, + error: null, + }); - expect(okSchema["~standard"].validate).toBeDefined(); + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("data"); }); - it("does not include jsonSchema when input lacks it", () => { - const validationOnly = createValidationOnlySchema(); - const okSchema = OkSchema(validationOnly); + it("validates complex objects", () => { + const userSchema = type({ id: "string", age: "number > 0" }); + const okUserSchema = OkSchema(userSchema); + + const result = okUserSchema["~standard"].validate({ + data: { id: "123", age: 25 }, + error: null, + }); - expect( - (okSchema["~standard"] as Record).jsonSchema, - ).toBeUndefined(); + expect(result).toEqual({ + value: { data: { id: "123", age: 25 }, error: null }, + }); }); }); }); describe("ErrSchema", () => { - describe("validation", () => { - it("validates Err variant successfully", () => { - const stringSchema = createMockStringSchema(); - const errSchema = ErrSchema(stringSchema); + describe("with Zod", () => { + const errorSchema = z.object({ code: z.string(), message: z.string() }); + const errSchema = ErrSchema(errorSchema); + it("validates Err variant successfully", () => { const result = errSchema["~standard"].validate({ data: null, - error: "error message", + error: { code: "NOT_FOUND", message: "User not found" }, }); expect(result).toEqual({ - value: { data: null, error: "error message" }, + value: { + data: null, + error: { code: "NOT_FOUND", message: "User not found" }, + }, }); }); it("rejects Ok variant (data not null)", () => { - const stringSchema = createMockStringSchema(); - const errSchema = ErrSchema(stringSchema); - const result = errSchema["~standard"].validate({ data: "some data", error: null, @@ -235,82 +191,106 @@ describe("ErrSchema", () => { }); it("propagates inner schema validation errors with path prefix", () => { - const stringSchema = createMockStringSchema(); - const errSchema = ErrSchema(stringSchema); + const result = errSchema["~standard"].validate({ + data: null, + error: { code: 123, message: "wrong type" }, + }); + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("error"); + }); + }); + + describe("with Valibot", () => { + const errorSchema = v.object({ + code: v.string(), + details: v.optional(v.string()), + }); + const errSchema = ErrSchema(errorSchema); + + it("validates Err variant successfully", () => { const result = errSchema["~standard"].validate({ data: null, - error: 123, + error: { code: "VALIDATION_ERROR" }, }); expect(result).toEqual({ - issues: [{ message: "Expected string", path: ["error"] }], + value: { data: null, error: { code: "VALIDATION_ERROR" } }, + }); + }); + + it("validates with optional fields", () => { + const result = errSchema["~standard"].validate({ + data: null, + error: { code: "ERROR", details: "Something went wrong" }, + }); + + expect(result).toEqual({ + value: { + data: null, + error: { code: "ERROR", details: "Something went wrong" }, + }, }); }); }); - describe("jsonSchema", () => { - it("generates JSON Schema for Err variant", () => { - const stringSchema = createMockStringSchema(); - const errSchema = ErrSchema(stringSchema); + describe("with ArkType", () => { + const errorSchema = type({ kind: "'error'", message: "string" }); + const errSchema = ErrSchema(errorSchema); - const inputSchema = errSchema["~standard"].jsonSchema.input({ - target: "draft-2020-12", + it("validates Err variant successfully", () => { + const result = errSchema["~standard"].validate({ + data: null, + error: { kind: "error", message: "Something failed" }, }); - expect(inputSchema).toEqual({ - type: "object", - properties: { - data: { type: "null" }, - error: { type: "string" }, + expect(result).toEqual({ + value: { + data: null, + error: { kind: "error", message: "Something failed" }, }, - required: ["data", "error"], - additionalProperties: false, }); }); }); }); describe("ResultSchema", () => { - describe("validation", () => { - it("validates Ok variant successfully", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); + describe("with Zod", () => { + const userSchema = z.object({ id: z.string(), name: z.string() }); + const errorSchema = z.object({ code: z.string(), message: z.string() }); + const resultSchema = ResultSchema(userSchema, errorSchema); + it("validates Ok variant successfully", () => { const result = resultSchema["~standard"].validate({ - data: { name: "Alice" }, + data: { id: "1", name: "Alice" }, error: null, }); expect(result).toEqual({ - value: { data: { name: "Alice" }, error: null }, + value: { data: { id: "1", name: "Alice" }, error: null }, }); }); it("validates Err variant successfully", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); - const result = resultSchema["~standard"].validate({ data: null, - error: "something went wrong", + error: { code: "NOT_FOUND", message: "User not found" }, }); expect(result).toEqual({ - value: { data: null, error: "something went wrong" }, + value: { + data: null, + error: { code: "NOT_FOUND", message: "User not found" }, + }, }); }); it("rejects invalid Result (neither null)", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); - const result = resultSchema["~standard"].validate({ - data: { name: "Alice" }, - error: "oops", + data: { id: "1", name: "Alice" }, + error: { code: "ERROR", message: "oops" }, }); expect(result).toEqual({ @@ -324,134 +304,217 @@ describe("ResultSchema", () => { }); it("propagates data schema errors with path prefix", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); + const result = resultSchema["~standard"].validate({ + data: { id: 123, name: "Alice" }, + error: null, + }); + + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("data"); + }); + + it("propagates error schema errors with path prefix", () => { + const result = resultSchema["~standard"].validate({ + data: null, + error: { code: 123, message: "wrong" }, + }); + + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + expect((issues[0] as { path: unknown[] }).path[0]).toBe("error"); + }); + }); + + describe("with Valibot", () => { + const dataSchema = v.object({ items: v.array(v.string()) }); + const errorSchema = v.object({ reason: v.string() }); + const resultSchema = ResultSchema(dataSchema, errorSchema); + it("validates Ok variant with arrays", () => { const result = resultSchema["~standard"].validate({ - data: { invalid: true }, + data: { items: ["a", "b", "c"] }, error: null, }); expect(result).toEqual({ - issues: [ - { message: "Expected object with name string", path: ["data"] }, - ], + value: { data: { items: ["a", "b", "c"] }, error: null }, }); }); - it("propagates error schema errors with path prefix", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); - + it("validates Err variant", () => { const result = resultSchema["~standard"].validate({ data: null, - error: 123, + error: { reason: "No items available" }, }); expect(result).toEqual({ - issues: [{ message: "Expected string", path: ["error"] }], + value: { data: null, error: { reason: "No items available" } }, }); }); }); - describe("jsonSchema", () => { - it("generates JSON Schema with oneOf for discriminated union", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); - const resultSchema = ResultSchema(dataSchema, errorSchema); + describe("with ArkType", () => { + const dataSchema = type({ count: "number", active: "boolean" }); + const errorSchema = type({ errorCode: "number" }); + const resultSchema = ResultSchema(dataSchema, errorSchema); - const inputSchema = resultSchema["~standard"].jsonSchema.input({ - target: "draft-2020-12", + it("validates Ok variant", () => { + const result = resultSchema["~standard"].validate({ + data: { count: 42, active: true }, + error: null, }); - expect(inputSchema).toEqual({ - oneOf: [ - { - type: "object", - properties: { - data: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }, - error: { type: "null" }, - }, - required: ["data", "error"], - additionalProperties: false, - }, - { - type: "object", - properties: { - data: { type: "null" }, - error: { type: "string" }, - }, - required: ["data", "error"], - additionalProperties: false, - }, - ], + expect(result).toEqual({ + value: { data: { count: 42, active: true }, error: null }, + }); + }); + + it("validates Err variant", () => { + const result = resultSchema["~standard"].validate({ + data: null, + error: { errorCode: 404 }, + }); + + expect(result).toEqual({ + value: { data: null, error: { errorCode: 404 } }, }); }); }); - describe("edge cases", () => { - it("handles both data and error being null", () => { - const dataSchema = createMockStringSchema(); - const errorSchema = createMockStringSchema(); + describe("mixed libraries", () => { + it("works with Zod data and Valibot error schemas", () => { + const dataSchema = z.object({ value: z.number() }); + const errorSchema = v.object({ msg: v.string() }); const resultSchema = ResultSchema(dataSchema, errorSchema); - const result = resultSchema["~standard"].validate({ - data: null, + const okResult = resultSchema["~standard"].validate({ + data: { value: 100 }, error: null, }); + expect(okResult).toEqual({ + value: { data: { value: 100 }, error: null }, + }); - expect(result).toEqual({ - value: { data: null, error: null }, + const errResult = resultSchema["~standard"].validate({ + data: null, + error: { msg: "failed" }, + }); + expect(errResult).toEqual({ + value: { data: null, error: { msg: "failed" } }, }); }); }); }); +describe("edge cases", () => { + it("handles both data and error being null", () => { + const dataSchema = z.string(); + const errorSchema = z.string(); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + const result = resultSchema["~standard"].validate({ + data: null, + error: null, + }); + + expect(result).toEqual({ + value: { data: null, error: null }, + }); + }); + + it("handles null as valid data value in Ok", () => { + const dataSchema = z.null(); + const okSchema = OkSchema(dataSchema); + + const result = okSchema["~standard"].validate({ + data: null, + error: null, + }); + + expect(result).toEqual({ + value: { data: null, error: null }, + }); + }); + + it("handles empty objects", () => { + const dataSchema = z.object({}); + const okSchema = OkSchema(dataSchema); + + const result = okSchema["~standard"].validate({ + data: {}, + error: null, + }); + + expect(result).toEqual({ + value: { data: {}, error: null }, + }); + }); + + it("handles deeply nested validation errors", () => { + const dataSchema = z.object({ + user: z.object({ + profile: z.object({ + name: z.string(), + }), + }), + }); + const okSchema = OkSchema(dataSchema); + + const result = okSchema["~standard"].validate({ + data: { user: { profile: { name: 123 } } }, + error: null, + }); + + expect(result).toHaveProperty("issues"); + const issues = (result as { issues: unknown[] }).issues; + expect(issues[0]).toHaveProperty("path"); + const path = (issues[0] as { path: unknown[] }).path; + expect(path[0]).toBe("data"); + }); +}); + describe("type inference", () => { - it("OkSchema infers correct types", () => { - const stringSchema = createMockStringSchema(); - const okSchema = OkSchema(stringSchema); + it("OkSchema infers correct types from Zod", () => { + const userSchema = z.object({ id: z.string() }); + const okSchema = OkSchema(userSchema); type Input = (typeof okSchema)["~standard"]["types"]["input"]; type Output = (typeof okSchema)["~standard"]["types"]["output"]; - const _inputCheck: Input = { data: "hello", error: null }; - const _outputCheck: Output = { data: "world", error: null }; + const _inputCheck: Input = { data: { id: "test" }, error: null }; + const _outputCheck: Output = { data: { id: "test" }, error: null }; expect(true).toBe(true); }); - it("ErrSchema infers correct types", () => { - const stringSchema = createMockStringSchema(); - const errSchema = ErrSchema(stringSchema); + it("ErrSchema infers correct types from Valibot", () => { + const errorSchema = v.object({ code: v.number() }); + const errSchema = ErrSchema(errorSchema); type Input = (typeof errSchema)["~standard"]["types"]["input"]; type Output = (typeof errSchema)["~standard"]["types"]["output"]; - const _inputCheck: Input = { data: null, error: "error" }; - const _outputCheck: Output = { data: null, error: "error" }; + const _inputCheck: Input = { data: null, error: { code: 404 } }; + const _outputCheck: Output = { data: null, error: { code: 500 } }; expect(true).toBe(true); }); it("ResultSchema infers correct union types", () => { - const dataSchema = createMockObjectSchema(); - const errorSchema = createMockStringSchema(); + const dataSchema = z.object({ name: z.string() }); + const errorSchema = z.object({ message: z.string() }); const resultSchema = ResultSchema(dataSchema, errorSchema); type Input = (typeof resultSchema)["~standard"]["types"]["input"]; type Output = (typeof resultSchema)["~standard"]["types"]["output"]; const _inputOk: Input = { data: { name: "Alice" }, error: null }; - const _inputErr: Input = { data: null, error: "oops" }; + const _inputErr: Input = { data: null, error: { message: "oops" } }; const _outputOk: Output = { data: { name: "Bob" }, error: null }; - const _outputErr: Output = { data: null, error: "error" }; + const _outputErr: Output = { data: null, error: { message: "error" } }; expect(true).toBe(true); }); From e1b3606823b6a033e0a63acb8c6608764a686626 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:32:00 -0800 Subject: [PATCH 3/7] refactor(standard-schema): extract error constants to reduce magic strings --- src/standard-schema/err.ts | 13 ++++--------- src/standard-schema/errors.ts | 9 +++++++++ src/standard-schema/index.ts | 1 + src/standard-schema/ok.ts | 13 ++++--------- src/standard-schema/result.ts | 14 ++++---------- src/standard-schema/standard-schema.test.ts | 19 ++++++------------- 6 files changed, 28 insertions(+), 41 deletions(-) create mode 100644 src/standard-schema/errors.ts diff --git a/src/standard-schema/err.ts b/src/standard-schema/err.ts index 96efeda..2aeaede 100644 --- a/src/standard-schema/err.ts +++ b/src/standard-schema/err.ts @@ -1,3 +1,4 @@ +import { ERRORS } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -48,23 +49,17 @@ function createErrValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: "Expected object" }] }; + return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; } if (!("data" in value) || !("error" in value)) { - return { - issues: [ - { message: "Expected object with 'data' and 'error' properties" }, - ], - }; + return { issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }] }; } const obj = value as { data: unknown; error: unknown }; if (obj.data !== null) { - return { - issues: [{ message: "Expected 'data' to be null for Err variant" }], - }; + return { issues: [{ message: ERRORS.EXPECTED_DATA_NULL }] }; } const innerResult = innerSchema["~standard"].validate(obj.error); diff --git a/src/standard-schema/errors.ts b/src/standard-schema/errors.ts new file mode 100644 index 0000000..698f3de --- /dev/null +++ b/src/standard-schema/errors.ts @@ -0,0 +1,9 @@ +export const ERRORS = { + EXPECTED_OBJECT: "Expected object", + EXPECTED_DATA_ERROR_PROPS: + "Expected object with 'data' and 'error' properties", + EXPECTED_ERROR_NULL: "Expected 'error' to be null for Ok variant", + EXPECTED_DATA_NULL: "Expected 'data' to be null for Err variant", + INVALID_RESULT: + "Invalid Result: exactly one of 'data' or 'error' must be null", +} as const; diff --git a/src/standard-schema/index.ts b/src/standard-schema/index.ts index b5ba9a8..b233da7 100644 --- a/src/standard-schema/index.ts +++ b/src/standard-schema/index.ts @@ -1,4 +1,5 @@ export { ErrSchema, type Err } from "./err.js"; +export { ERRORS } from "./errors.js"; export { OkSchema, type Ok } from "./ok.js"; export { ResultSchema, type Result } from "./result.js"; export { diff --git a/src/standard-schema/ok.ts b/src/standard-schema/ok.ts index 372d6df..d925c79 100644 --- a/src/standard-schema/ok.ts +++ b/src/standard-schema/ok.ts @@ -1,3 +1,4 @@ +import { ERRORS } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -48,23 +49,17 @@ function createOkValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: "Expected object" }] }; + return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; } if (!("data" in value) || !("error" in value)) { - return { - issues: [ - { message: "Expected object with 'data' and 'error' properties" }, - ], - }; + return { issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }] }; } const obj = value as { data: unknown; error: unknown }; if (obj.error !== null) { - return { - issues: [{ message: "Expected 'error' to be null for Ok variant" }], - }; + return { issues: [{ message: ERRORS.EXPECTED_ERROR_NULL }] }; } const innerResult = innerSchema["~standard"].validate(obj.data); diff --git a/src/standard-schema/result.ts b/src/standard-schema/result.ts index 465cedd..ed432bb 100644 --- a/src/standard-schema/result.ts +++ b/src/standard-schema/result.ts @@ -1,3 +1,4 @@ +import { ERRORS } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -73,14 +74,12 @@ function createResultValidate< >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: "Expected object" }] }; + return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; } if (!("data" in value) || !("error" in value)) { return { - issues: [ - { message: "Expected object with 'data' and 'error' properties" }, - ], + issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }], }; } @@ -95,12 +94,7 @@ function createResultValidate< if (!isOkVariant && !isErrVariant) { return { - issues: [ - { - message: - "Invalid Result: exactly one of 'data' or 'error' must be null", - }, - ], + issues: [{ message: ERRORS.INVALID_RESULT }], }; } diff --git a/src/standard-schema/standard-schema.test.ts b/src/standard-schema/standard-schema.test.ts index fa538eb..de3d77c 100644 --- a/src/standard-schema/standard-schema.test.ts +++ b/src/standard-schema/standard-schema.test.ts @@ -2,7 +2,7 @@ import { type } from "arktype"; import * as v from "valibot"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ErrSchema, OkSchema, ResultSchema } from "./index.js"; +import { ERRORS, ErrSchema, OkSchema, ResultSchema } from "./index.js"; describe("OkSchema", () => { describe("with Zod", () => { @@ -21,16 +21,14 @@ describe("OkSchema", () => { it("rejects non-object values", () => { const result = okSchema["~standard"].validate("not an object"); - expect(result).toEqual({ issues: [{ message: "Expected object" }] }); + expect(result).toEqual({ issues: [{ message: ERRORS.EXPECTED_OBJECT }] }); }); it("rejects objects without data/error properties", () => { const result = okSchema["~standard"].validate({ foo: "bar" }); expect(result).toEqual({ - issues: [ - { message: "Expected object with 'data' and 'error' properties" }, - ], + issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }], }); }); @@ -41,7 +39,7 @@ describe("OkSchema", () => { }); expect(result).toEqual({ - issues: [{ message: "Expected 'error' to be null for Ok variant" }], + issues: [{ message: ERRORS.EXPECTED_ERROR_NULL }], }); }); @@ -186,7 +184,7 @@ describe("ErrSchema", () => { }); expect(result).toEqual({ - issues: [{ message: "Expected 'data' to be null for Err variant" }], + issues: [{ message: ERRORS.EXPECTED_DATA_NULL }], }); }); @@ -294,12 +292,7 @@ describe("ResultSchema", () => { }); expect(result).toEqual({ - issues: [ - { - message: - "Invalid Result: exactly one of 'data' or 'error' must be null", - }, - ], + issues: [{ message: ERRORS.INVALID_RESULT }], }); }); From 706ffb1550566814d0aadfb70fc623a6bb963562 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:16:32 -0800 Subject: [PATCH 4/7] refactor(standard-schema): co-locate issue messages with paths in ISSUES constant --- src/standard-schema/err.ts | 8 +++--- src/standard-schema/errors.ts | 29 ++++++++++++++------- src/standard-schema/index.ts | 2 +- src/standard-schema/ok.ts | 8 +++--- src/standard-schema/result.ts | 12 +++------ src/standard-schema/standard-schema.test.ts | 20 +++++--------- 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/standard-schema/err.ts b/src/standard-schema/err.ts index 2aeaede..28d33a5 100644 --- a/src/standard-schema/err.ts +++ b/src/standard-schema/err.ts @@ -1,4 +1,4 @@ -import { ERRORS } from "./errors.js"; +import { ISSUES } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -49,17 +49,17 @@ function createErrValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; + return { issues: [ISSUES.EXPECTED_OBJECT] }; } if (!("data" in value) || !("error" in value)) { - return { issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }] }; + return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; } const obj = value as { data: unknown; error: unknown }; if (obj.data !== null) { - return { issues: [{ message: ERRORS.EXPECTED_DATA_NULL }] }; + return { issues: [ISSUES.EXPECTED_DATA_NULL] }; } const innerResult = innerSchema["~standard"].validate(obj.error); diff --git a/src/standard-schema/errors.ts b/src/standard-schema/errors.ts index 698f3de..e5d0024 100644 --- a/src/standard-schema/errors.ts +++ b/src/standard-schema/errors.ts @@ -1,9 +1,20 @@ -export const ERRORS = { - EXPECTED_OBJECT: "Expected object", - EXPECTED_DATA_ERROR_PROPS: - "Expected object with 'data' and 'error' properties", - EXPECTED_ERROR_NULL: "Expected 'error' to be null for Ok variant", - EXPECTED_DATA_NULL: "Expected 'data' to be null for Err variant", - INVALID_RESULT: - "Invalid Result: exactly one of 'data' or 'error' must be null", -} as const; +import type { StandardSchemaV1 } from "./types.js"; + +export const ISSUES = { + EXPECTED_OBJECT: { message: "Expected object" }, + EXPECTED_DATA_ERROR_PROPS: { + message: "Expected object with 'data' and 'error' properties", + }, + EXPECTED_ERROR_NULL: { + message: "Expected 'error' to be null for Ok variant", + path: ["error"], + }, + EXPECTED_DATA_NULL: { + message: "Expected 'data' to be null for Err variant", + path: ["data"], + }, + INVALID_RESULT: { + message: "Invalid Result: exactly one of 'data' or 'error' must be null", + path: ["data", "error"], + }, +} as const satisfies Record; diff --git a/src/standard-schema/index.ts b/src/standard-schema/index.ts index b233da7..a8635dd 100644 --- a/src/standard-schema/index.ts +++ b/src/standard-schema/index.ts @@ -1,5 +1,5 @@ export { ErrSchema, type Err } from "./err.js"; -export { ERRORS } from "./errors.js"; +export { ISSUES } from "./errors.js"; export { OkSchema, type Ok } from "./ok.js"; export { ResultSchema, type Result } from "./result.js"; export { diff --git a/src/standard-schema/ok.ts b/src/standard-schema/ok.ts index d925c79..0d135cc 100644 --- a/src/standard-schema/ok.ts +++ b/src/standard-schema/ok.ts @@ -1,4 +1,4 @@ -import { ERRORS } from "./errors.js"; +import { ISSUES } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -49,17 +49,17 @@ function createOkValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; + return { issues: [ISSUES.EXPECTED_OBJECT] }; } if (!("data" in value) || !("error" in value)) { - return { issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }] }; + return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; } const obj = value as { data: unknown; error: unknown }; if (obj.error !== null) { - return { issues: [{ message: ERRORS.EXPECTED_ERROR_NULL }] }; + return { issues: [ISSUES.EXPECTED_ERROR_NULL] }; } const innerResult = innerSchema["~standard"].validate(obj.data); diff --git a/src/standard-schema/result.ts b/src/standard-schema/result.ts index ed432bb..5677750 100644 --- a/src/standard-schema/result.ts +++ b/src/standard-schema/result.ts @@ -1,4 +1,4 @@ -import { ERRORS } from "./errors.js"; +import { ISSUES } from "./errors.js"; import { hasJsonSchema, hasValidate, @@ -74,13 +74,11 @@ function createResultValidate< >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [{ message: ERRORS.EXPECTED_OBJECT }] }; + return { issues: [ISSUES.EXPECTED_OBJECT] }; } if (!("data" in value) || !("error" in value)) { - return { - issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }], - }; + return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; } const obj = value as { data: unknown; error: unknown }; @@ -93,9 +91,7 @@ function createResultValidate< } if (!isOkVariant && !isErrVariant) { - return { - issues: [{ message: ERRORS.INVALID_RESULT }], - }; + return { issues: [ISSUES.INVALID_RESULT] }; } if (isOkVariant) { diff --git a/src/standard-schema/standard-schema.test.ts b/src/standard-schema/standard-schema.test.ts index de3d77c..feda642 100644 --- a/src/standard-schema/standard-schema.test.ts +++ b/src/standard-schema/standard-schema.test.ts @@ -2,7 +2,7 @@ import { type } from "arktype"; import * as v from "valibot"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ERRORS, ErrSchema, OkSchema, ResultSchema } from "./index.js"; +import { ErrSchema, ISSUES, OkSchema, ResultSchema } from "./index.js"; describe("OkSchema", () => { describe("with Zod", () => { @@ -21,15 +21,13 @@ describe("OkSchema", () => { it("rejects non-object values", () => { const result = okSchema["~standard"].validate("not an object"); - expect(result).toEqual({ issues: [{ message: ERRORS.EXPECTED_OBJECT }] }); + expect(result).toEqual({ issues: [ISSUES.EXPECTED_OBJECT] }); }); it("rejects objects without data/error properties", () => { const result = okSchema["~standard"].validate({ foo: "bar" }); - expect(result).toEqual({ - issues: [{ message: ERRORS.EXPECTED_DATA_ERROR_PROPS }], - }); + expect(result).toEqual({ issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }); }); it("rejects Err variant (error not null)", () => { @@ -38,9 +36,7 @@ describe("OkSchema", () => { error: "some error", }); - expect(result).toEqual({ - issues: [{ message: ERRORS.EXPECTED_ERROR_NULL }], - }); + expect(result).toEqual({ issues: [ISSUES.EXPECTED_ERROR_NULL] }); }); it("propagates inner schema validation errors with path prefix", () => { @@ -183,9 +179,7 @@ describe("ErrSchema", () => { error: null, }); - expect(result).toEqual({ - issues: [{ message: ERRORS.EXPECTED_DATA_NULL }], - }); + expect(result).toEqual({ issues: [ISSUES.EXPECTED_DATA_NULL] }); }); it("propagates inner schema validation errors with path prefix", () => { @@ -291,9 +285,7 @@ describe("ResultSchema", () => { error: { code: "ERROR", message: "oops" }, }); - expect(result).toEqual({ - issues: [{ message: ERRORS.INVALID_RESULT }], - }); + expect(result).toEqual({ issues: [ISSUES.INVALID_RESULT] }); }); it("propagates data schema errors with path prefix", () => { From 7505d669f30e8c637a07a34b2d005160f0c3d748 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:50:11 -0800 Subject: [PATCH 5/7] refactor(standard-schema): rename ISSUES to FAILURES with complete failure results Call sites simplified from: return { issues: [ISSUES.EXPECTED_OBJECT] }; To: return FAILURES.EXPECTED_OBJECT; --- src/standard-schema/err.ts | 8 +++--- src/standard-schema/errors.ts | 20 ------------- src/standard-schema/failures.ts | 31 +++++++++++++++++++++ src/standard-schema/index.ts | 2 +- src/standard-schema/ok.ts | 8 +++--- src/standard-schema/result.ts | 8 +++--- src/standard-schema/standard-schema.test.ts | 12 ++++---- 7 files changed, 50 insertions(+), 39 deletions(-) delete mode 100644 src/standard-schema/errors.ts create mode 100644 src/standard-schema/failures.ts diff --git a/src/standard-schema/err.ts b/src/standard-schema/err.ts index 28d33a5..a59a42b 100644 --- a/src/standard-schema/err.ts +++ b/src/standard-schema/err.ts @@ -1,4 +1,4 @@ -import { ISSUES } from "./errors.js"; +import { FAILURES } from "./failures.js"; import { hasJsonSchema, hasValidate, @@ -49,17 +49,17 @@ function createErrValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [ISSUES.EXPECTED_OBJECT] }; + return FAILURES.EXPECTED_OBJECT; } if (!("data" in value) || !("error" in value)) { - return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; + return FAILURES.EXPECTED_DATA_ERROR_PROPS; } const obj = value as { data: unknown; error: unknown }; if (obj.data !== null) { - return { issues: [ISSUES.EXPECTED_DATA_NULL] }; + return FAILURES.EXPECTED_DATA_NULL; } const innerResult = innerSchema["~standard"].validate(obj.error); diff --git a/src/standard-schema/errors.ts b/src/standard-schema/errors.ts deleted file mode 100644 index e5d0024..0000000 --- a/src/standard-schema/errors.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { StandardSchemaV1 } from "./types.js"; - -export const ISSUES = { - EXPECTED_OBJECT: { message: "Expected object" }, - EXPECTED_DATA_ERROR_PROPS: { - message: "Expected object with 'data' and 'error' properties", - }, - EXPECTED_ERROR_NULL: { - message: "Expected 'error' to be null for Ok variant", - path: ["error"], - }, - EXPECTED_DATA_NULL: { - message: "Expected 'data' to be null for Err variant", - path: ["data"], - }, - INVALID_RESULT: { - message: "Invalid Result: exactly one of 'data' or 'error' must be null", - path: ["data", "error"], - }, -} as const satisfies Record; diff --git a/src/standard-schema/failures.ts b/src/standard-schema/failures.ts new file mode 100644 index 0000000..63ad088 --- /dev/null +++ b/src/standard-schema/failures.ts @@ -0,0 +1,31 @@ +export const FAILURES = { + EXPECTED_OBJECT: { issues: [{ message: "Expected object" }] }, + EXPECTED_DATA_ERROR_PROPS: { + issues: [{ message: "Expected object with 'data' and 'error' properties" }], + }, + EXPECTED_ERROR_NULL: { + issues: [ + { + message: "Expected 'error' to be null for Ok variant", + path: ["error"], + }, + ], + }, + EXPECTED_DATA_NULL: { + issues: [ + { + message: "Expected 'data' to be null for Err variant", + path: ["data"], + }, + ], + }, + INVALID_RESULT: { + issues: [ + { + message: + "Invalid Result: exactly one of 'data' or 'error' must be null", + path: ["data", "error"], + }, + ], + }, +} as const; diff --git a/src/standard-schema/index.ts b/src/standard-schema/index.ts index a8635dd..557aa79 100644 --- a/src/standard-schema/index.ts +++ b/src/standard-schema/index.ts @@ -1,5 +1,5 @@ export { ErrSchema, type Err } from "./err.js"; -export { ISSUES } from "./errors.js"; +export { FAILURES } from "./failures.js"; export { OkSchema, type Ok } from "./ok.js"; export { ResultSchema, type Result } from "./result.js"; export { diff --git a/src/standard-schema/ok.ts b/src/standard-schema/ok.ts index 0d135cc..07cc123 100644 --- a/src/standard-schema/ok.ts +++ b/src/standard-schema/ok.ts @@ -1,4 +1,4 @@ -import { ISSUES } from "./errors.js"; +import { FAILURES } from "./failures.js"; import { hasJsonSchema, hasValidate, @@ -49,17 +49,17 @@ function createOkValidate( >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [ISSUES.EXPECTED_OBJECT] }; + return FAILURES.EXPECTED_OBJECT; } if (!("data" in value) || !("error" in value)) { - return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; + return FAILURES.EXPECTED_DATA_ERROR_PROPS; } const obj = value as { data: unknown; error: unknown }; if (obj.error !== null) { - return { issues: [ISSUES.EXPECTED_ERROR_NULL] }; + return FAILURES.EXPECTED_ERROR_NULL; } const innerResult = innerSchema["~standard"].validate(obj.data); diff --git a/src/standard-schema/result.ts b/src/standard-schema/result.ts index 5677750..2eabb7e 100644 --- a/src/standard-schema/result.ts +++ b/src/standard-schema/result.ts @@ -1,4 +1,4 @@ -import { ISSUES } from "./errors.js"; +import { FAILURES } from "./failures.js"; import { hasJsonSchema, hasValidate, @@ -74,11 +74,11 @@ function createResultValidate< >["validate"] { return (value: unknown) => { if (typeof value !== "object" || value === null) { - return { issues: [ISSUES.EXPECTED_OBJECT] }; + return FAILURES.EXPECTED_OBJECT; } if (!("data" in value) || !("error" in value)) { - return { issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }; + return FAILURES.EXPECTED_DATA_ERROR_PROPS; } const obj = value as { data: unknown; error: unknown }; @@ -91,7 +91,7 @@ function createResultValidate< } if (!isOkVariant && !isErrVariant) { - return { issues: [ISSUES.INVALID_RESULT] }; + return FAILURES.INVALID_RESULT; } if (isOkVariant) { diff --git a/src/standard-schema/standard-schema.test.ts b/src/standard-schema/standard-schema.test.ts index feda642..536c077 100644 --- a/src/standard-schema/standard-schema.test.ts +++ b/src/standard-schema/standard-schema.test.ts @@ -2,7 +2,7 @@ import { type } from "arktype"; import * as v from "valibot"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ErrSchema, ISSUES, OkSchema, ResultSchema } from "./index.js"; +import { ErrSchema, FAILURES, OkSchema, ResultSchema } from "./index.js"; describe("OkSchema", () => { describe("with Zod", () => { @@ -21,13 +21,13 @@ describe("OkSchema", () => { it("rejects non-object values", () => { const result = okSchema["~standard"].validate("not an object"); - expect(result).toEqual({ issues: [ISSUES.EXPECTED_OBJECT] }); + expect(result).toEqual(FAILURES.EXPECTED_OBJECT); }); it("rejects objects without data/error properties", () => { const result = okSchema["~standard"].validate({ foo: "bar" }); - expect(result).toEqual({ issues: [ISSUES.EXPECTED_DATA_ERROR_PROPS] }); + expect(result).toEqual(FAILURES.EXPECTED_DATA_ERROR_PROPS); }); it("rejects Err variant (error not null)", () => { @@ -36,7 +36,7 @@ describe("OkSchema", () => { error: "some error", }); - expect(result).toEqual({ issues: [ISSUES.EXPECTED_ERROR_NULL] }); + expect(result).toEqual(FAILURES.EXPECTED_ERROR_NULL); }); it("propagates inner schema validation errors with path prefix", () => { @@ -179,7 +179,7 @@ describe("ErrSchema", () => { error: null, }); - expect(result).toEqual({ issues: [ISSUES.EXPECTED_DATA_NULL] }); + expect(result).toEqual(FAILURES.EXPECTED_DATA_NULL); }); it("propagates inner schema validation errors with path prefix", () => { @@ -285,7 +285,7 @@ describe("ResultSchema", () => { error: { code: "ERROR", message: "oops" }, }); - expect(result).toEqual({ issues: [ISSUES.INVALID_RESULT] }); + expect(result).toEqual(FAILURES.INVALID_RESULT); }); it("propagates data schema errors with path prefix", () => { From 9f681ce1e7c4d8759bf54c3a3204daf01ce528c2 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:53:27 -0800 Subject: [PATCH 6/7] refactor(failures): enforce type safety with StandardSchemaV1.FailureResult --- src/standard-schema/failures.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/standard-schema/failures.ts b/src/standard-schema/failures.ts index 63ad088..b8dd966 100644 --- a/src/standard-schema/failures.ts +++ b/src/standard-schema/failures.ts @@ -1,3 +1,5 @@ +import type { StandardSchemaV1 } from "./types.js"; + export const FAILURES = { EXPECTED_OBJECT: { issues: [{ message: "Expected object" }] }, EXPECTED_DATA_ERROR_PROPS: { @@ -28,4 +30,4 @@ export const FAILURES = { }, ], }, -} as const; +} as const satisfies Record; From f2b3ebdabcaa43eae0e72ad2115d680b98129504 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:01:23 -0800 Subject: [PATCH 7/7] changeset: add Standard Schema Result wrappers feature --- .changeset/standard-schema-result-wrappers.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/standard-schema-result-wrappers.md diff --git a/.changeset/standard-schema-result-wrappers.md b/.changeset/standard-schema-result-wrappers.md new file mode 100644 index 0000000..3975647 --- /dev/null +++ b/.changeset/standard-schema-result-wrappers.md @@ -0,0 +1,12 @@ +--- +"wellcrafted": minor +--- + +Add Standard Schema v1 compliant Result wrappers for schema validation libraries + +New `wellcrafted/standard-schema` module provides: +- `OkSchema(schema)` - wraps any Standard Schema into `{ data: T, error: null }` +- `ErrSchema(schema)` - wraps any Standard Schema into `{ data: null, error: E }` +- `ResultSchema(dataSchema, errorSchema)` - creates discriminated union of Ok | Err + +Works with Zod, Valibot, ArkType, and any Standard Schema v1 compliant library. Preserves schema capabilities (validate, jsonSchema) based on input schema features.