Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Standard Schema spec #1213

Merged
merged 13 commits into from
Feb 4, 2025
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'];
}
26 changes: 26 additions & 0 deletions test-d/standard-schema.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { string } 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);

const stringToNumber = string.transform((x) => x.length);

const input = {} as StandardSchemaV1.InferInput<typeof stringToNumber>;
expectType<unknown>(input);

const output = {} as StandardSchemaV1.InferOutput<typeof stringToNumber>;
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(stringToNumber, "I'm a string"));
Loading