Skip to content

Commit

Permalink
Implement Standard Schema spec (#1213)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Driessen <[email protected]>
  • Loading branch information
marcbouchenoire and nvie authored Feb 4, 2025
1 parent 99c1e0c commit 8116662
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 51 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [Unreleased]

- Implement the [Standard Schema](https://standardschema.dev/) specification.

## [2.5.0] - 2024-09-18

**New decoders:**
Expand Down
24 changes: 12 additions & 12 deletions docs/Decoder.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ for name in DECODER_METHODS:
]]]-->
---

<a href="#verify">#</a> **.verify**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">T</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L173-L185 'Source')
<a href="#verify">#</a> **.verify**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">T</i> [<small>(source)</small>](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.
Expand All @@ -70,7 +70,7 @@ number.verify('hello'); // throws

---

<a href="#value">#</a> **.value**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">T | undefined</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L187-L197 'Source')
<a href="#value">#</a> **.value**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">T | undefined</i> [<small>(source)</small>](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.
Expand All @@ -96,7 +96,7 @@ string.value(42); // undefined
---

<a href="#decode">#</a> **.decode**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">DecodeResult&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L161-L171 'Source')
<a href="#decode">#</a> **.decode**(blob: <i style="color: #267f99">mixed</i>): <i style="color: #267f99">DecodeResult&lt;T&gt;</i> [<small>(source)</small>](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.
Expand All @@ -118,7 +118,7 @@ number.decode('hi'); // { ok: false, error: { type: 'scalar', value: 'hi', text

---

<a href="#transform">#</a> **.transform**&lt;<i style="color: #267f99">V</i>&gt;(transformFn: <i style="color: #267f99">(T) =&gt; V</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L199-L207 'Source')
<a href="#transform">#</a> **.transform**&lt;<i style="color: #267f99">V</i>&gt;(transformFn: <i style="color: #267f99">(T) =&gt; V</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](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
Expand All @@ -138,7 +138,7 @@ upper.verify(4); // throws

---

<a href="#refine">#</a> **.refine**(predicate: <i style="color: #267f99">T =&gt; boolean</i>, message: <i style="color: #267f99">string</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L209-L222 'Source')
<a href="#refine">#</a> **.refine**(predicate: <i style="color: #267f99">T =&gt; boolean</i>, message: <i style="color: #267f99">string</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](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
Expand All @@ -163,7 +163,7 @@ In TypeScript, if you provide a predicate that also is a [type predicate](https:

---

<a href="#reject">#</a> **.reject**(rejectFn: <i style="color: #267f99">T =&gt; string | null</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L266-L284 'Source')
<a href="#reject">#</a> **.reject**(rejectFn: <i style="color: #267f99">T =&gt; string | null</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](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
Expand Down Expand Up @@ -196,7 +196,7 @@ decoder.verify({ id: 123, _name: 'Vincent' }) // throws: "Disallowed keys: _n

---

<a href="#describe">#</a> **.describe**(message: <i style="color: #267f99">string</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L286-L303 'Source')
<a href="#describe">#</a> **.describe**(message: <i style="color: #267f99">string</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](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.
Expand All @@ -208,10 +208,10 @@ const vowel = oneOf(['a', 'e', 'i', 'o', 'u'])

---

<a href="#then">#</a> **.then**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99">(blob: T, ok, err) =&gt; DecodeResult&lt;V&gt; | <a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L224-L242 'Source')
<a href="#then">#</a> **.then**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99">(blob: T, ok, err) =&gt; DecodeResult&lt;V&gt; | <a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L230-L248 'Source')
{: #then .signature}

<a href="#then">#</a> **.then**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L224-L242 'Source')
<a href="#then">#</a> **.then**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](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
Expand All @@ -225,10 +225,10 @@ current decoder as its input.
---

<a href="#pipe">#</a> **.pipe**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L244-L264 'Source')
<a href="#pipe">#</a> **.pipe**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L250-L270 'Source')
{: #pipe .signature}

<a href="#pipe">#</a> **.pipe**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99">(blob: T) =&gt; <a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L244-L264 'Source')
<a href="#pipe">#</a> **.pipe**&lt;<i style="color: #267f99">V</i>&gt;(next: <i style="color: #267f99">(blob: T) =&gt; <a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;V&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L250-L270 'Source')
{: #pipe .signature}

```tsx
Expand Down Expand Up @@ -266,5 +266,5 @@ string
);
```

<!--[[[end]]] (checksum: a87184fec5d1484dd8db645269a5a972) -->
<!--[[[end]]] (checksum: c31601057bc8857b48dc4a8d6945a44a) -->
<!-- prettier-ignore-end -->
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1517,7 +1517,7 @@ const decoder = select(

---

<a href="#define">#</a> **define**&lt;<i style="color: #267f99">T</i>&gt;(fn: <i style="color: #267f99">(blob: unknown, ok, err) =&gt; DecodeResult&lt;T&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L147-L316 'Source')
<a href="#define">#</a> **define**&lt;<i style="color: #267f99">T</i>&gt;(fn: <i style="color: #267f99">(blob: unknown, ok, err) =&gt; DecodeResult&lt;T&gt;</i>): <i style="color: #267f99"><a href="/Decoder.html" style="color: inherit">Decoder</a>&lt;T&gt;</i> [<small>(source)</small>](https://github.com/nvie/decoders/tree/main/src/core/Decoder.ts#L153-L335 'Source')
{: #define .signature}

Defines a new `Decoder<T>`, by implementing a custom acceptance function.
Expand Down Expand Up @@ -1660,5 +1660,5 @@ const treeDecoder: Decoder<Tree> = object({
});
```

<!--[[[end]]] (checksum: e875efd895ee6c480c011b9e3113ed5e)-->
<!--[[[end]]] (checksum: 8764d0df66062b4b0fbed65fa1292a90)-->
<!-- prettier-ignore-end -->
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion src/core/Decoder.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Result<T, Annotation>;

Expand Down Expand Up @@ -99,6 +100,11 @@ export interface Decoder<T> {
*/
pipe<V, D extends Decoder<V>>(next: D): Decoder<DecoderType<D>>;
pipe<V, D extends Decoder<V>>(next: (blob: T) => D): Decoder<DecoderType<D>>;

/**
* The Standard Schema interface for this decoder.
*/
'~standard': StandardSchemaV1.Props<unknown, T>;
}

/**
Expand Down Expand Up @@ -312,6 +318,19 @@ export function define<T>(fn: AcceptanceFn<T>): Decoder<T> {
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 };
}
},
},
});
}

Expand Down
43 changes: 43 additions & 0 deletions src/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Std.Issue> {
// 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, []));
}
67 changes: 67 additions & 0 deletions src/core/standard-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** The Standard Schema interface. */
export interface StandardSchemaV1<Input = unknown, Output = Input> {
/** The Standard Schema properties. */
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}

export declare namespace StandardSchemaV1 {
/** The Standard Schema properties interface. */
export interface Props<Input = unknown, Output = Input> {
/** 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<Output> | Promise<Result<Output>>;
/** Inferred types associated with the schema. */
readonly types?: Types<Input, Output> | undefined;
}

/** The result interface of the validate function. */
export type Result<Output> = SuccessResult<Output> | FailureResult;

/** The result interface if validation succeeds. */
export interface SuccessResult<Output> {
/** 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<Issue>;
}
/** 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<PropertyKey | PathSegment> | 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<Input = unknown, Output = Input> {
/** 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<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['input'];

/** Infers the output type of a Standard Schema. */
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['output'];
}
25 changes: 25 additions & 0 deletions test-d/standard-schema.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { string, numeric } from '../dist';
import { expectAssignable, expectError, expectType } from 'tsd';
import type { StandardSchemaV1 } from '@standard-schema/spec';

expectAssignable<StandardSchemaV1>(string);
expectAssignable<StandardSchemaV1<unknown>>(string);
expectAssignable<StandardSchemaV1<unknown, string>>(string);

declare const input: StandardSchemaV1.InferInput<typeof numeric>;
declare const output: StandardSchemaV1.InferOutput<typeof numeric>;

expectType<unknown>(input);
expectType<number>(output);

// A generic function that accepts an arbitrary spec-compliant validator
declare function standardValidate<T extends StandardSchemaV1>(
schema: T,
input: StandardSchemaV1.InferInput<T>,
): StandardSchemaV1.InferOutput<T>;

// `string` and `stringToNumber` are accepted by `standardValidate`
expectError(standardValidate(() => "I'm not a standard validator", 42));
expectType<string>(standardValidate(string, "I'm a string"));
expectType<number>(standardValidate(numeric, "I'm a string"));
expectType<number>(standardValidate(numeric, ['not', 'a', 'string']));
Loading

0 comments on commit 8116662

Please sign in to comment.