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. 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..a59a42b --- /dev/null +++ b/src/standard-schema/err.ts @@ -0,0 +1,175 @@ +import { FAILURES } from "./failures.js"; +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 FAILURES.EXPECTED_OBJECT; + } + + if (!("data" in value) || !("error" in value)) { + return FAILURES.EXPECTED_DATA_ERROR_PROPS; + } + + const obj = value as { data: unknown; error: unknown }; + + if (obj.data !== null) { + return FAILURES.EXPECTED_DATA_NULL; + } + + 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/failures.ts b/src/standard-schema/failures.ts new file mode 100644 index 0000000..b8dd966 --- /dev/null +++ b/src/standard-schema/failures.ts @@ -0,0 +1,33 @@ +import type { StandardSchemaV1 } from "./types.js"; + +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 satisfies Record; diff --git a/src/standard-schema/index.ts b/src/standard-schema/index.ts new file mode 100644 index 0000000..557aa79 --- /dev/null +++ b/src/standard-schema/index.ts @@ -0,0 +1,12 @@ +export { ErrSchema, type Err } from "./err.js"; +export { FAILURES } from "./failures.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..07cc123 --- /dev/null +++ b/src/standard-schema/ok.ts @@ -0,0 +1,175 @@ +import { FAILURES } from "./failures.js"; +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 FAILURES.EXPECTED_OBJECT; + } + + if (!("data" in value) || !("error" in value)) { + return FAILURES.EXPECTED_DATA_ERROR_PROPS; + } + + const obj = value as { data: unknown; error: unknown }; + + if (obj.error !== null) { + return FAILURES.EXPECTED_ERROR_NULL; + } + + 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..2eabb7e --- /dev/null +++ b/src/standard-schema/result.ts @@ -0,0 +1,273 @@ +import { FAILURES } from "./failures.js"; +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 FAILURES.EXPECTED_OBJECT; + } + + if (!("data" in value) || !("error" in value)) { + return FAILURES.EXPECTED_DATA_ERROR_PROPS; + } + + 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 FAILURES.INVALID_RESULT; + } + + 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..536c077 --- /dev/null +++ b/src/standard-schema/standard-schema.test.ts @@ -0,0 +1,506 @@ +import { type } from "arktype"; +import * as v from "valibot"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ErrSchema, FAILURES, OkSchema, ResultSchema } from "./index.js"; + +describe("OkSchema", () => { + 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, + }); + + expect(result).toEqual({ value: { data: "hello", error: null } }); + }); + + it("rejects non-object values", () => { + const result = okSchema["~standard"].validate("not an object"); + + expect(result).toEqual(FAILURES.EXPECTED_OBJECT); + }); + + it("rejects objects without data/error properties", () => { + const result = okSchema["~standard"].validate({ foo: "bar" }); + + expect(result).toEqual(FAILURES.EXPECTED_DATA_ERROR_PROPS); + }); + + it("rejects Err variant (error not null)", () => { + const result = okSchema["~standard"].validate({ + data: null, + error: "some error", + }); + + expect(result).toEqual(FAILURES.EXPECTED_ERROR_NULL); + }); + + it("propagates inner schema validation errors with path prefix", () => { + 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({ + value: { data: { id: "123", name: "Alice" }, error: null }, + }); + }); + }); + + describe("with Valibot", () => { + const stringSchema = v.string(); + const okSchema = OkSchema(stringSchema); + + it("validates Ok variant successfully", () => { + const result = okSchema["~standard"].validate({ + data: "hello from valibot", + error: null, + }); + + expect(result).toEqual({ + value: { data: "hello from valibot", error: null }, + }); + }); + + it("propagates validation errors with path prefix", () => { + const result = okSchema["~standard"].validate({ + data: 42, + 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 = v.object({ + id: v.string(), + email: v.pipe(v.string(), v.email()), + }); + const okUserSchema = OkSchema(userSchema); + + 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("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(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 = type({ id: "string", age: "number > 0" }); + const okUserSchema = OkSchema(userSchema); + + const result = okUserSchema["~standard"].validate({ + data: { id: "123", age: 25 }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { id: "123", age: 25 }, error: null }, + }); + }); + }); +}); + +describe("ErrSchema", () => { + 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: { code: "NOT_FOUND", message: "User not found" }, + }); + + expect(result).toEqual({ + value: { + data: null, + error: { code: "NOT_FOUND", message: "User not found" }, + }, + }); + }); + + it("rejects Ok variant (data not null)", () => { + const result = errSchema["~standard"].validate({ + data: "some data", + error: null, + }); + + expect(result).toEqual(FAILURES.EXPECTED_DATA_NULL); + }); + + it("propagates inner schema validation errors with path prefix", () => { + 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: { code: "VALIDATION_ERROR" }, + }); + + expect(result).toEqual({ + 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("with ArkType", () => { + const errorSchema = type({ kind: "'error'", message: "string" }); + const errSchema = ErrSchema(errorSchema); + + it("validates Err variant successfully", () => { + const result = errSchema["~standard"].validate({ + data: null, + error: { kind: "error", message: "Something failed" }, + }); + + expect(result).toEqual({ + value: { + data: null, + error: { kind: "error", message: "Something failed" }, + }, + }); + }); + }); +}); + +describe("ResultSchema", () => { + 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: { id: "1", name: "Alice" }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { id: "1", name: "Alice" }, error: null }, + }); + }); + + it("validates Err variant successfully", () => { + const result = resultSchema["~standard"].validate({ + data: null, + error: { code: "NOT_FOUND", message: "User not found" }, + }); + + expect(result).toEqual({ + value: { + data: null, + error: { code: "NOT_FOUND", message: "User not found" }, + }, + }); + }); + + it("rejects invalid Result (neither null)", () => { + const result = resultSchema["~standard"].validate({ + data: { id: "1", name: "Alice" }, + error: { code: "ERROR", message: "oops" }, + }); + + expect(result).toEqual(FAILURES.INVALID_RESULT); + }); + + it("propagates data schema errors with path prefix", () => { + 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: { items: ["a", "b", "c"] }, + error: null, + }); + + expect(result).toEqual({ + value: { data: { items: ["a", "b", "c"] }, error: null }, + }); + }); + + it("validates Err variant", () => { + const result = resultSchema["~standard"].validate({ + data: null, + error: { reason: "No items available" }, + }); + + expect(result).toEqual({ + value: { data: null, error: { reason: "No items available" } }, + }); + }); + }); + + describe("with ArkType", () => { + const dataSchema = type({ count: "number", active: "boolean" }); + const errorSchema = type({ errorCode: "number" }); + const resultSchema = ResultSchema(dataSchema, errorSchema); + + it("validates Ok variant", () => { + const result = resultSchema["~standard"].validate({ + data: { count: 42, active: true }, + error: null, + }); + + 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("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 okResult = resultSchema["~standard"].validate({ + data: { value: 100 }, + error: null, + }); + expect(okResult).toEqual({ + value: { data: { value: 100 }, 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 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: { id: "test" }, error: null }; + const _outputCheck: Output = { data: { id: "test" }, error: null }; + + expect(true).toBe(true); + }); + + 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: { code: 404 } }; + const _outputCheck: Output = { data: null, error: { code: 500 } }; + + expect(true).toBe(true); + }); + + it("ResultSchema infers correct union types", () => { + 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: { message: "oops" } }; + const _outputOk: Output = { data: { name: "Bob" }, error: null }; + const _outputErr: Output = { data: null, error: { message: "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",