diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e92a5cbf..34d561d8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -25,6 +25,10 @@ module.exports = { }, rules: { + // Disable these checks + '@typescript-eslint/no-namespace': 'off', + + // Enable these checks 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'import/first': 'error', diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc07530..fb4acad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [Unreleased] +- Implement the [Standard Schema](https://standardschema.dev/) specification. + ## [2.5.0] - 2024-09-18 **New decoders:** diff --git a/docs/Decoder.md b/docs/Decoder.md index e36db830..f34a4579 100644 --- a/docs/Decoder.md +++ b/docs/Decoder.md @@ -45,7 +45,7 @@ for name in DECODER_METHODS: ]]]--> --- -# **.verify**(blob: mixed): T [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L173-L185 'Source') +# **.verify**(blob: mixed): T [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L179-L191 'Source') {: #verify .signature} Verifies the untrusted/unknown input and either accepts or rejects it. @@ -70,7 +70,7 @@ number.verify('hello'); // throws --- -# **.value**(blob: mixed): T | undefined [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L187-L197 'Source') +# **.value**(blob: mixed): T | undefined [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L193-L203 'Source') {: #value .signature} Verifies the untrusted/unknown input and either accepts or rejects it. @@ -96,7 +96,7 @@ string.value(42); // undefined --- -# **.decode**(blob: mixed): DecodeResult<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L161-L171 'Source') +# **.decode**(blob: mixed): DecodeResult<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L167-L177 'Source') {: #decode .signature} Verifies the untrusted/unknown input and either accepts or rejects it. @@ -118,7 +118,7 @@ number.decode('hi'); // { ok: false, error: { type: 'scalar', value: 'hi', text --- -# **.transform**<V>(transformFn: (T) => V): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L199-L207 'Source') +# **.transform**<V>(transformFn: (T) => V): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L205-L213 'Source') {: #transform .signature} Accepts any value the given decoder accepts, and on success, will call @@ -138,7 +138,7 @@ upper.verify(4); // throws --- -# **.refine**(predicate: T => boolean, message: string): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L209-L222 'Source') +# **.refine**(predicate: T => boolean, message: string): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L215-L228 'Source') {: #refine .signature} Adds an extra predicate to a decoder. The new decoder is like the @@ -163,7 +163,7 @@ In TypeScript, if you provide a predicate that also is a [type predicate](https: --- -# **.reject**(rejectFn: T => string | null): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L266-L284 'Source') +# **.reject**(rejectFn: T => string | null): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L272-L290 'Source') {: #reject .signature} Adds an extra predicate to a decoder. The new decoder is like the @@ -196,7 +196,7 @@ decoder.verify({ id: 123, _name: 'Vincent' }) // throws: "Disallowed keys: _n --- -# **.describe**(message: string): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L286-L303 'Source') +# **.describe**(message: string): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L292-L309 'Source') {: #describe .signature} Uses the given decoder, but will use an alternative error message in case it rejects. This can be used to simplify or shorten otherwise long or low-level/technical errors. @@ -208,10 +208,10 @@ const vowel = oneOf(['a', 'e', 'i', 'o', 'u']) --- -# **.then**<V>(next: (blob: T, ok, err) => DecodeResult<V> | Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L224-L242 'Source') +# **.then**<V>(next: (blob: T, ok, err) => DecodeResult<V> | Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L230-L248 'Source') {: #then .signature} -# **.then**<V>(next: Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L224-L242 'Source') +# **.then**<V>(next: Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L230-L248 'Source') {: #then .signature} Send the output of the current decoder into another decoder or acceptance @@ -225,10 +225,10 @@ current decoder as its input. --- -# **.pipe**<V>(next: Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L244-L264 'Source') +# **.pipe**<V>(next: Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L250-L270 'Source') {: #pipe .signature} -# **.pipe**<V>(next: (blob: T) => Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L244-L264 'Source') +# **.pipe**<V>(next: (blob: T) => Decoder<V>): Decoder<V> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L250-L270 'Source') {: #pipe .signature} ```tsx @@ -266,5 +266,5 @@ string ); ``` - + diff --git a/docs/api.md b/docs/api.md index ee0dfba1..fcc49bab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1517,7 +1517,7 @@ const decoder = select( --- -# **define**<T>(fn: (blob: unknown, ok, err) => DecodeResult<T>): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L147-L316 'Source') +# **define**<T>(fn: (blob: unknown, ok, err) => DecodeResult<T>): Decoder<T> [(source)](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L153-L335 'Source') {: #define .signature} Defines a new `Decoder`, by implementing a custom acceptance function. @@ -1660,5 +1660,5 @@ const treeDecoder: Decoder = object({ }); ``` - + diff --git a/package-lock.json b/package-lock.json index 278597ab..64cefa3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.3", "@release-it/keep-a-changelog": "^6.0.0", + "@standard-schema/spec": "^1.0.0", "@types/eslint": "^8.56.10", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", @@ -2327,6 +2328,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -12437,6 +12445,12 @@ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true }, + "@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, "@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", diff --git a/package.json b/package.json index 92039bb1..d78c7bd4 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.3", "@release-it/keep-a-changelog": "^6.0.0", + "@standard-schema/spec": "^1.0.0", "@types/eslint": "^8.56.10", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", diff --git a/src/core/Decoder.ts b/src/core/Decoder.ts index 6187147d..5100cb38 100644 --- a/src/core/Decoder.ts +++ b/src/core/Decoder.ts @@ -1,9 +1,10 @@ import type { Annotation } from './annotate'; import { annotate, isAnnotation } from './annotate'; import type { Formatter } from './format'; -import { formatInline } from './format'; +import { formatAsIssues, formatInline } from './format'; import type { Result } from './Result'; import { err as makeErr, ok as makeOk } from './Result'; +import type { StandardSchemaV1 } from './standard-schema'; export type DecodeResult = Result; @@ -99,6 +100,11 @@ export interface Decoder { */ pipe>(next: D): Decoder>; pipe>(next: (blob: T) => D): Decoder>; + + /** + * The Standard Schema interface for this decoder. + */ + '~standard': StandardSchemaV1.Props; } /** @@ -312,6 +318,19 @@ export function define(fn: AcceptanceFn): Decoder { describe, then, pipe, + '~standard': { + version: 1, + vendor: 'decoders', + validate: (blob) => { + const result = decode(blob); + if (result.ok) { + return { value: result.value }; + } else { + const issues = formatAsIssues(result.error); + return { issues }; + } + }, + }, }); } diff --git a/src/core/format.ts b/src/core/format.ts index dbd603c6..6ff8177a 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -7,6 +7,7 @@ import type { ObjectAnnotation, OpaqueAnnotation, } from './annotate'; +import type { StandardSchemaV1 as Std } from './standard-schema'; export type Formatter = (err: Annotation) => string | Error; @@ -177,3 +178,45 @@ export function formatInline(ann: Annotation): string { export function formatShort(ann: Annotation): string { return summarize(ann, []).join('\n'); } + +function* iterAnnotation(ann: Annotation, stack: PropertyKey[]): Generator { + // If the current annotation has a message, yield it first + if (ann.text) { + if (stack.length > 0) { + yield { message: ann.text, path: [...stack] }; + } else { + yield { message: ann.text }; + } + } + + switch (ann.type) { + case 'array': { + let index = 0; + for (const item of ann.items) { + stack.push(index++); + yield* iterAnnotation(item, stack); + stack.pop(); + } + break; + } + + case 'object': { + for (const [key, value] of ann.fields) { + stack.push(key); + yield* iterAnnotation(value, stack); + stack.pop(); + } + break; + } + + case 'scalar': + case 'opaque': { + // Nothing extra to iterate here, they are leafs + break; + } + } +} + +export function formatAsIssues(ann: Annotation): Std.Issue[] { + return Array.from(iterAnnotation(ann, [])); +} diff --git a/src/core/standard-schema.ts b/src/core/standard-schema.ts new file mode 100644 index 00000000..81ff8c04 --- /dev/null +++ b/src/core/standard-schema.ts @@ -0,0 +1,67 @@ +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly '~standard': StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: (value: unknown) => Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** The non-existent issues. */ + readonly issues?: undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + /** The issue interface of the failure output. */ + export interface 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. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard Schema types interface. */ + export interface 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 Schema. */ + export type InferInput = NonNullable< + Schema['~standard']['types'] + >['input']; + + /** Infers the output type of a Standard Schema. */ + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output']; +} diff --git a/test-d/standard-schema.test-d.ts b/test-d/standard-schema.test-d.ts new file mode 100644 index 00000000..04d884fd --- /dev/null +++ b/test-d/standard-schema.test-d.ts @@ -0,0 +1,25 @@ +import { string, numeric } from '../dist'; +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +expectAssignable(string); +expectAssignable>(string); +expectAssignable>(string); + +declare const input: StandardSchemaV1.InferInput; +declare const output: StandardSchemaV1.InferOutput; + +expectType(input); +expectType(output); + +// A generic function that accepts an arbitrary spec-compliant validator +declare function standardValidate( + schema: T, + input: StandardSchemaV1.InferInput, +): StandardSchemaV1.InferOutput; + +// `string` and `stringToNumber` are accepted by `standardValidate` +expectError(standardValidate(() => "I'm not a standard validator", 42)); +expectType(standardValidate(string, "I'm a string")); +expectType(standardValidate(numeric, "I'm a string")); +expectType(standardValidate(numeric, ['not', 'a', 'string'])); diff --git a/test/core/standard-schema.test.ts b/test/core/standard-schema.test.ts new file mode 100644 index 00000000..6909a711 --- /dev/null +++ b/test/core/standard-schema.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'vitest'; + +import { array } from '~/arrays'; +import { number } from '~/numbers'; +import { exact, object } from '~/objects'; +import { string } from '~/strings'; +import { either } from '~/unions'; + +describe('standard-schema', () => { + test('valid', async () => { + const schema = string; + const result = await schema['~standard'].validate("I'm a string"); + if (result.issues) { + expect.unreachable('Expected no issues'); + } + + expect(result.value).toEqual("I'm a string"); + }); + + test('invalid', async () => { + const schema = string; + const result = await schema['~standard'].validate(42); + expect(result.issues).toEqual([{ message: 'Must be string' }]); + }); + + test('invalid (nesting with array paths)', async () => { + const schema = array(exact({ a: string })); + + expect((await schema['~standard'].validate(42)).issues).toEqual([ + { message: 'Must be an array' }, + ]); + + expect((await schema['~standard'].validate([{ a: '' }, 42, 42])).issues).toEqual([ + { message: 'Must be an object (at index 1)', path: [1] }, + ]); + + expect((await schema['~standard'].validate([{}, {}])).issues).toEqual([ + { message: "Missing key: 'a' (at index 0)", path: [0] }, + ]); + }); + + test('invalid (nesting)', async () => { + const schema = object({ + a: object({ + b: exact({ + c: string, + }), + }), + }); + + expect( + (await schema['~standard'].validate({ b: { b: { c: 'hi' } } })).issues, + ).toEqual([{ message: "Missing key: 'a'" }]); + + expect((await schema['~standard'].validate({ a: { b: 42 } })).issues).toEqual([ + { message: 'Must be an object', path: ['a', 'b'] }, + ]); + + expect((await schema['~standard'].validate({ a: { b: 42 } })).issues).toEqual([ + { message: 'Must be an object', path: ['a', 'b'] }, + ]); + + expect((await schema['~standard'].validate({ a: { b: { c: 42 } } })).issues).toEqual([ + { message: 'Must be string', path: ['a', 'b', 'c'] }, + ]); + + expect( + (await schema['~standard'].validate({ a: { b: { c: 'hi', d: 'hi' } } })).issues, + ).toEqual([{ message: "Unexpected extra keys: 'd'", path: ['a', 'b'] }]); + + expect((await schema['~standard'].validate({ a: { b: {} } })).issues).toEqual([ + { message: "Missing key: 'c'", path: ['a', 'b'] }, + ]); + }); + + test('invalid (nesting)', async () => { + const schema = object({ + a: object({ + b: exact({ + c: string, + }), + }), + }); + + expect( + (await schema['~standard'].validate({ b: { b: { c: 'hi' } } })).issues, + ).toEqual([{ message: "Missing key: 'a'" }]); + + expect((await schema['~standard'].validate({ a: { b: 42 } })).issues).toEqual([ + { message: 'Must be an object', path: ['a', 'b'] }, + ]); + + expect((await schema['~standard'].validate({ a: { b: 42 } })).issues).toEqual([ + { message: 'Must be an object', path: ['a', 'b'] }, + ]); + + expect((await schema['~standard'].validate({ a: { b: { c: 42 } } })).issues).toEqual([ + { message: 'Must be string', path: ['a', 'b', 'c'] }, + ]); + + expect( + (await schema['~standard'].validate({ a: { b: { c: 'hi', d: 'hi' } } })).issues, + ).toEqual([{ message: "Unexpected extra keys: 'd'", path: ['a', 'b'] }]); + + expect((await schema['~standard'].validate({ a: { b: {} } })).issues).toEqual([ + { message: "Missing key: 'c'", path: ['a', 'b'] }, + ]); + }); + + test('invalid (branches)', async () => { + const schema = object({ + a: either(object({ foo: string }), object({ bar: number })), + }); + + const result = await schema['~standard'].validate({ + a: { + foo: 42, + bar: 'hi', + }, + }); + + // Branching cannot be elegantly handled with a single flat list of issues + expect(result.issues).toEqual([ + { + message: + "Either:\n- Value at key 'foo': Must be string\n- Value at key 'bar': Must be number", + path: ['a'], + }, + ]); + }); +}); diff --git a/test/result.test.ts b/test/result.test.ts deleted file mode 100644 index 1d4cccd7..00000000 --- a/test/result.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { err, ok } from '~/core'; - -describe('Result', () => { - const r1 = ok(42); - const r2 = ok("I'm a string"); - const r3 = err(new Error('Proper JS error')); - const r4 = err('a reason'); - - test('inspection', () => { - expect(r1.ok).toBe(true); - expect(r2.ok).toBe(true); - expect(r3.ok).toBe(false); - expect(r4.ok).toBe(false); - }); - - test('convenience constructors', () => { - expect(ok(42).ok).toBe(true); - expect(err('oops').ok).toBe(false); - }); - - test('value access', () => { - expect(r1.value).toBe(42); - expect(r2.value).toBe("I'm a string"); - expect(r3.value).toBeUndefined(); - expect(r4.value).toBeUndefined(); - }); - - test('error access', () => { - expect(r1.error).toBeUndefined(); - expect(r2.error).toBeUndefined(); - expect(r3.error).toEqual(new Error('Proper JS error')); - expect(r4.error).toEqual('a reason'); - }); -});