Skip to content

Commit 36a3554

Browse files
untio11pavadeli
andcommitted
feat: Introduce StandardSchemaV1 interface in Skunkteam Types (#108)
* feat: Introduce StandardSchemaV1 interface in Skunkteam Types Hooked into the existing Valid/Invalid Conversion tests. * chore: Forgot to run pre-pr command * suggesting simpler messages in standard validate --------- Co-authored-by: Paco van der Linden <[email protected]>
1 parent 61e5705 commit 36a3554

16 files changed

+301
-16
lines changed

etc/types.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
```ts
66

7+
import { StandardSchemaV1 } from '@standard-schema/spec';
8+
79
// @public
810
export function array<ElementType extends BaseTypeImpl<any>>(...args: [name: string, elementType: ElementType, typeConfig?: ArrayTypeConfig] | [elementType: ElementType, typeConfig?: ArrayTypeConfig]): TypeImpl<ArrayType<ElementType, TypeOf<ElementType>, Array<TypeOf<ElementType>>>>;
911

@@ -56,7 +58,8 @@ export abstract class BaseObjectLikeTypeImpl<ResultType, TypeConfig = unknown> e
5658
}
5759

5860
// @public
59-
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType> {
61+
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType> {
62+
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType>;
6063
// @internal
6164
readonly [designType]: ResultType;
6265
abstract accept<R>(visitor: Visitor<R>): R;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
2+
3+
[Home](./index.md) &gt; [@skunkteam/types](./types.md) &gt; [BaseTypeImpl](./types.basetypeimpl.md) &gt; ["\~standard"](./types.basetypeimpl.__standard_.md)
4+
5+
## BaseTypeImpl."\~standard" property
6+
7+
Skunkteam Types implementation of \[StandardSchemaV1\](https://standardschema.dev/)
8+
9+
**Signature:**
10+
11+
```typescript
12+
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType>;
13+
```

markdown/types.basetypeimpl.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ The base-class of all type-implementations.
99
**Signature:**
1010

1111
```typescript
12-
declare abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>
12+
declare abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType>
1313
```
1414
15-
**Implements:** [TypeLink](./types.typelink.md)<!-- -->&lt;ResultType&gt;
15+
**Implements:** [TypeLink](./types.typelink.md)<!-- -->&lt;ResultType&gt;, StandardSchemaV1&lt;unknown, ResultType&gt;
1616
1717
## Remarks
1818
@@ -22,6 +22,7 @@ All type-implementations must extend this base class. Use [createType()](./types
2222
2323
| Property | Modifiers | Type | Description |
2424
| --------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25+
| ["\~standard"](./types.basetypeimpl.__standard_.md) | <code>readonly</code> | StandardSchemaV1.Props&lt;unknown, ResultType&gt; | Skunkteam Types implementation of \[StandardSchemaV1\](https://standardschema.dev/) |
2526
| [basicType](./types.basetypeimpl.basictype.md) | <p><code>abstract</code></p><p><code>readonly</code></p> | [BasicType](./types.basictype.md) \| 'mixed' | The kind of values this type validates. |
2627
| [check](./types.basetypeimpl.check.md) | <code>readonly</code> | (this: void, input: unknown) =&gt; ResultType | Asserts that a value conforms to this Type and returns the input as is, if it does. |
2728
| [customValidators](./types.basetypeimpl.customvalidators.md) | <p><code>protected</code></p><p><code>readonly</code></p> | ReadonlyArray&lt;[Validator](./types.validator.md)<!-- -->&lt;unknown&gt;&gt; | Additional custom validation added using [withValidation](./types.basetypeimpl.withvalidation.md) or [withConstraint](./types.basetypeimpl.withconstraint.md)<!-- -->. |

package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"typescript": "^5.2.2"
6565
},
6666
"dependencies": {
67+
"@standard-schema/spec": "^1.0.0",
6768
"@types/big.js": "^6.2.0",
6869
"big.js": "^6.2.1",
6970
"tslib": "^2.6.2"

src/base-type.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec';
12
import { autoCast } from './autocast';
3+
import { mapFailureToStandardIssues } from './error-reporter';
24
import type {
35
BasicType,
46
Branded,
@@ -44,7 +46,9 @@ import { ValidationError } from './validation-error';
4446
* @remarks
4547
* All type-implementations must extend this base class. Use {@link createType} to create a {@link Type} from a type-implementation.
4648
*/
47-
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType> {
49+
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown>
50+
implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType>
51+
{
4852
/**
4953
* The associated TypeScript-type of a Type.
5054
* @internal
@@ -120,6 +124,7 @@ export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements
120124
autoCastAll?: BaseTypeImpl<ResultType, TypeConfig>;
121125
boundCheck?: BaseTypeImpl<ResultType, TypeConfig>['check'];
122126
boundIs?: BaseTypeImpl<ResultType, TypeConfig>['is'];
127+
standardSchema?: StandardSchemaV1.Props<ResultType>;
123128
} = {};
124129

125130
protected createAutoCastAllType(): Type<ResultType> {
@@ -513,6 +518,22 @@ export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements
513518
protected combineConfig(oldConfig: TypeConfig, newConfig: TypeConfig): TypeConfig {
514519
return { ...oldConfig, ...newConfig };
515520
}
521+
522+
/**
523+
* Skunkteam Types implementation of [StandardSchemaV1](https://standardschema.dev/)
524+
*/
525+
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType> {
526+
return (this._instanceCache.standardSchema ??= {
527+
version: 1,
528+
vendor: 'skunkteam-types',
529+
validate: (value: unknown): StandardSchemaV1.Result<ResultType> => {
530+
// Note: we always call the 'construct' version of `this.validate`, which will parse `value` before typechecking. The
531+
// StandardSchemaV1 interface doesn't provide room to make our distinction between 'checking' and 'constructing'.
532+
const result = this.validate(value);
533+
return result.ok ? { value: result.value } : { issues: mapFailureToStandardIssues(result) };
534+
},
535+
});
536+
}
516537
}
517538
Object.defineProperties(BaseTypeImpl.prototype, {
518539
...Object.getOwnPropertyDescriptors(Function.prototype),

src/error-reporter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec';
12
import type { BaseObjectLikeTypeImpl, BaseTypeImpl } from './base-type';
23
import type { BasicType, Failure, FailureDetails, OneOrMore, ValidationDetails } from './interfaces';
34
import { an, basicType, castArray, checkOneOrMore, humanList, isSingle, plural, printKey, printPath, printValue, remove } from './utils';
@@ -32,6 +33,11 @@ export function reportError(root: Failure, level = -1, omitInput?: boolean): str
3233
return msg + reportDetails(details, childLevel);
3334
}
3435

36+
/** Maps the top-level failure details to individual issues in the StandardSchema format. */
37+
export function mapFailureToStandardIssues(root: Failure): readonly StandardSchemaV1.Issue[] {
38+
return root.details.sort(detailSorter).map(detail => ({ message: detailMessage(detail, 0), path: detail.path }));
39+
}
40+
3541
function reportDetails(details: FailureDetails[], level: number) {
3642
const missingProps: Record<string, OneOrMore<FailureDetails & { kind: 'missing property' }>> = {};
3743
for (const detail of details) {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * from './autocast';
22
export * from './base-type';
3-
export * from './error-reporter';
3+
export { reportError } from './error-reporter';
44
export * from './interfaces';
55
export * from './simple-type';
66
export * from './symbols';

src/testutils.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
12
/* eslint-disable @typescript-eslint/no-unsafe-return */
23

4+
import type { StandardSchemaV1 } from '@standard-schema/spec';
5+
import assert from 'assert';
36
import type { BaseObjectLikeTypeImpl, BaseTypeImpl } from './base-type';
47
import type { BasicType, LiteralValue, NumberTypeConfig, OneOrMore, Properties, StringTypeConfig, Type, Visitor } from './interfaces';
58
import type { ArrayType, InterfaceType, IntersectionType, KeyofType, LiteralType, RecordType, UnionType, unknownRecord } from './types';
69
import { an, basicType, printValue } from './utils';
710
import { ValidationError } from './validation-error';
811

12+
/** Test case for a type. */
913
export interface TypeTestCase {
14+
/** The expected name of the type */
1015
name: string;
16+
/** The type to test. Can be a single type or an array of types. */
1117
type: Type<any> | Type<any>[];
1218
basicType?: BasicType | 'mixed';
19+
/** Values that the type should accept as being valid. Note that the parser is not used for these values. */
1320
validValues?: unknown[];
14-
invalidValues?: [value: unknown, message: string | string[] | RegExp][];
21+
/**
22+
* Values that the type should not accept as being valid. Again, no parser is used for these values. Note that this input is also used
23+
* for invalidConversions unless provided explicitly. Therefore it is also possible to provide the third parameter (`issues`) here. Look
24+
* at invalidConversions for more details.
25+
*/
26+
invalidValues?: [value: unknown, message: string | string[] | RegExp, issues?: StandardSchemaV1.Issue[]][];
27+
/** Values that type should accept as being valid after applying any parsers. */
1528
validConversions?: [input: unknown, value: unknown][];
16-
invalidConversions?: [input: unknown, message: string | string[] | RegExp][];
29+
/**
30+
* Values that the type should not accept as being valid after applying any parsers. These cases are also applied to the standard schema
31+
* validation because that is linked to our validation "in construct mode". The third parameter can be given to override our default
32+
* expectations of the standard schema error messages. In a lot of cases we can determine this automatically, but in some cases we
33+
* cannot.
34+
*/
35+
invalidConversions?: [input: unknown, message: string | string[] | RegExp, issues?: StandardSchemaV1.Issue[]][];
1736
}
1837

1938
/**
@@ -35,7 +54,9 @@ export function testTypeImpl({
3554
validValues,
3655
invalidValues,
3756
validConversions,
38-
invalidConversions,
57+
// Also test the same conditions using the `construct` method, instead of only using the `check` method. This also ensures we take the
58+
// standard schema validation into account.
59+
invalidConversions = invalidValues,
3960
}: TypeTestCase): void {
4061
describe(`test: ${name}`, () => {
4162
Array.isArray(types) ? describe.each(types)('implementation %#', theTests) : theTests(types);
@@ -87,11 +108,13 @@ export function testTypeImpl({
87108
expect(type.apply(undefined, [input])).toEqual(output);
88109
expect(type.bind(undefined, input)()).toEqual(output);
89110
expect(type.call(undefined, input)).toEqual(output);
111+
expect(standardValidate(type, input)).toEqual({ value: output });
90112
});
91113

92114
invalidConversions &&
93-
test.each(invalidConversions)('will not convert: %p', (value, message) => {
115+
test.each(invalidConversions)('will not convert: %p', (value, message, issues = defaultIssues(message)) => {
94116
expect(() => type(value)).toThrowWithMessage(ValidationErrorForTest, Array.isArray(message) ? message.join('\n') : message);
117+
expect(standardValidate(type, value)).toEqual({ issues });
95118
});
96119
}
97120
}
@@ -209,3 +232,40 @@ class CreateExampleVisitor implements Visitor<unknown> {
209232
function hasExample<T>(obj: BaseTypeImpl<T>): obj is BaseTypeImpl<T> & { example: T } {
210233
return 'example' in obj;
211234
}
235+
236+
/**
237+
* Helper function around StandardSchema validation interface to incorporate it in the existing conversion tests.
238+
*
239+
* Note that Skunkteam Types has a distinction between checking if an input conforms to a schema (Type) as-is (`.is()`, `.check()`) vs
240+
* validating if an input can be parsed and converted into the schema (`.construct()`). This makes it non-trivial to fully incorporate
241+
* the StandardSchema interface into the existing test-suite.
242+
*/
243+
function standardValidate<T>(schema: StandardSchemaV1<T>, input: unknown): StandardSchemaV1.Result<T> {
244+
const result = schema['~standard'].validate(input);
245+
if (result instanceof Promise) throw new TypeError('No asynchronous type validation in Skunkteam Types');
246+
return result;
247+
}
248+
249+
function defaultIssues(input: string | RegExp | string[]): StandardSchemaV1.Issue[] {
250+
const message = Array.isArray(input) ? input.join('\n') : typeof input === 'string' ? input : expect.stringMatching(input);
251+
// Perform some string parsing on the error to guess what the standard schema issue should look like. This is just to prevent having to
252+
// configure a lot of expectations, but does not cover every possibility. Especially multiple errors will have to be stated explicitly.
253+
//
254+
// Things we do here:
255+
// - Remove the common header that says "error in {optional context} [TheTypeName]:", because that should not be part of the issues
256+
// list.
257+
// - Try to guess the path if any.
258+
const hasPath = typeof message === 'string' && /^error in [^[]*\[.*?\](?: at [^<]*<(?<path>[^>]+)>)?: (?<message>[^]*)$/.exec(message);
259+
if (hasPath) {
260+
assert(hasPath.groups);
261+
return [
262+
{
263+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript is wrong here
264+
path: hasPath.groups.path?.split('.').map(key => (/^\[\d+\]$/.test(key) ? +key.slice(1, -1) : key)),
265+
message: hasPath.groups.message,
266+
},
267+
];
268+
}
269+
// if (typeof message === 'string') message = message.replace(/^error in .*\[.*\]: /, '');
270+
return [{ message }];
271+
}

0 commit comments

Comments
 (0)