Skip to content

Commit

Permalink
feat: Introduce StandardSchemaV1 interface in Skunkteam Types (#108)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
untio11 and pavadeli committed Feb 18, 2025
1 parent 61e5705 commit 36a3554
Show file tree
Hide file tree
Showing 16 changed files with 301 additions and 16 deletions.
5 changes: 4 additions & 1 deletion etc/types.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
```ts

import { StandardSchemaV1 } from '@standard-schema/spec';

// @public
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>>>>;

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

// @public
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType> {
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType> {
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType>;
// @internal
readonly [designType]: ResultType;
abstract accept<R>(visitor: Visitor<R>): R;
Expand Down
13 changes: 13 additions & 0 deletions markdown/types.basetypeimpl.__standard_.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@skunkteam/types](./types.md) &gt; [BaseTypeImpl](./types.basetypeimpl.md) &gt; ["\~standard"](./types.basetypeimpl.__standard_.md)

## BaseTypeImpl."\~standard" property

Skunkteam Types implementation of \[StandardSchemaV1\](https://standardschema.dev/)

**Signature:**

```typescript
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType>;
```
5 changes: 3 additions & 2 deletions markdown/types.basetypeimpl.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ The base-class of all type-implementations.
**Signature:**

```typescript
declare abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>
declare abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType>
```
**Implements:** [TypeLink](./types.typelink.md)<!-- -->&lt;ResultType&gt;
**Implements:** [TypeLink](./types.typelink.md)<!-- -->&lt;ResultType&gt;, StandardSchemaV1&lt;unknown, ResultType&gt;
## Remarks
Expand All @@ -22,6 +22,7 @@ All type-implementations must extend this base class. Use [createType()](./types
| Property | Modifiers | Type | Description |
| --------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ["\~standard"](./types.basetypeimpl.__standard_.md) | <code>readonly</code> | StandardSchemaV1.Props&lt;unknown, ResultType&gt; | Skunkteam Types implementation of \[StandardSchemaV1\](https://standardschema.dev/) |
| [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. |
| [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. |
| [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)<!-- -->. |
Expand Down
6 changes: 6 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 @@ -64,6 +64,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/big.js": "^6.2.0",
"big.js": "^6.2.1",
"tslib": "^2.6.2"
Expand Down
23 changes: 22 additions & 1 deletion src/base-type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { autoCast } from './autocast';
import { mapFailureToStandardIssues } from './error-reporter';
import type {
BasicType,
Branded,
Expand Down Expand Up @@ -44,7 +46,9 @@ import { ValidationError } from './validation-error';
* @remarks
* All type-implementations must extend this base class. Use {@link createType} to create a {@link Type} from a type-implementation.
*/
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements TypeLink<ResultType> {
export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown>
implements TypeLink<ResultType>, StandardSchemaV1<unknown, ResultType>
{
/**
* The associated TypeScript-type of a Type.
* @internal
Expand Down Expand Up @@ -120,6 +124,7 @@ export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements
autoCastAll?: BaseTypeImpl<ResultType, TypeConfig>;
boundCheck?: BaseTypeImpl<ResultType, TypeConfig>['check'];
boundIs?: BaseTypeImpl<ResultType, TypeConfig>['is'];
standardSchema?: StandardSchemaV1.Props<ResultType>;
} = {};

protected createAutoCastAllType(): Type<ResultType> {
Expand Down Expand Up @@ -513,6 +518,22 @@ export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements
protected combineConfig(oldConfig: TypeConfig, newConfig: TypeConfig): TypeConfig {
return { ...oldConfig, ...newConfig };
}

/**
* Skunkteam Types implementation of [StandardSchemaV1](https://standardschema.dev/)
*/
get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType> {
return (this._instanceCache.standardSchema ??= {
version: 1,
vendor: 'skunkteam-types',
validate: (value: unknown): StandardSchemaV1.Result<ResultType> => {
// Note: we always call the 'construct' version of `this.validate`, which will parse `value` before typechecking. The
// StandardSchemaV1 interface doesn't provide room to make our distinction between 'checking' and 'constructing'.
const result = this.validate(value);
return result.ok ? { value: result.value } : { issues: mapFailureToStandardIssues(result) };
},
});
}
}
Object.defineProperties(BaseTypeImpl.prototype, {
...Object.getOwnPropertyDescriptors(Function.prototype),
Expand Down
6 changes: 6 additions & 0 deletions src/error-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { BaseObjectLikeTypeImpl, BaseTypeImpl } from './base-type';
import type { BasicType, Failure, FailureDetails, OneOrMore, ValidationDetails } from './interfaces';
import { an, basicType, castArray, checkOneOrMore, humanList, isSingle, plural, printKey, printPath, printValue, remove } from './utils';
Expand Down Expand Up @@ -32,6 +33,11 @@ export function reportError(root: Failure, level = -1, omitInput?: boolean): str
return msg + reportDetails(details, childLevel);
}

/** Maps the top-level failure details to individual issues in the StandardSchema format. */
export function mapFailureToStandardIssues(root: Failure): readonly StandardSchemaV1.Issue[] {
return root.details.sort(detailSorter).map(detail => ({ message: detailMessage(detail, 0), path: detail.path }));
}

function reportDetails(details: FailureDetails[], level: number) {
const missingProps: Record<string, OneOrMore<FailureDetails & { kind: 'missing property' }>> = {};
for (const detail of details) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * from './autocast';
export * from './base-type';
export * from './error-reporter';
export { reportError } from './error-reporter';
export * from './interfaces';
export * from './simple-type';
export * from './symbols';
Expand Down
68 changes: 64 additions & 4 deletions src/testutils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */

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

/** Test case for a type. */
export interface TypeTestCase {
/** The expected name of the type */
name: string;
/** The type to test. Can be a single type or an array of types. */
type: Type<any> | Type<any>[];
basicType?: BasicType | 'mixed';
/** Values that the type should accept as being valid. Note that the parser is not used for these values. */
validValues?: unknown[];
invalidValues?: [value: unknown, message: string | string[] | RegExp][];
/**
* 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
* for invalidConversions unless provided explicitly. Therefore it is also possible to provide the third parameter (`issues`) here. Look
* at invalidConversions for more details.
*/
invalidValues?: [value: unknown, message: string | string[] | RegExp, issues?: StandardSchemaV1.Issue[]][];
/** Values that type should accept as being valid after applying any parsers. */
validConversions?: [input: unknown, value: unknown][];
invalidConversions?: [input: unknown, message: string | string[] | RegExp][];
/**
* Values that the type should not accept as being valid after applying any parsers. These cases are also applied to the standard schema
* validation because that is linked to our validation "in construct mode". The third parameter can be given to override our default
* expectations of the standard schema error messages. In a lot of cases we can determine this automatically, but in some cases we
* cannot.
*/
invalidConversions?: [input: unknown, message: string | string[] | RegExp, issues?: StandardSchemaV1.Issue[]][];
}

/**
Expand All @@ -35,7 +54,9 @@ export function testTypeImpl({
validValues,
invalidValues,
validConversions,
invalidConversions,
// Also test the same conditions using the `construct` method, instead of only using the `check` method. This also ensures we take the
// standard schema validation into account.
invalidConversions = invalidValues,
}: TypeTestCase): void {
describe(`test: ${name}`, () => {
Array.isArray(types) ? describe.each(types)('implementation %#', theTests) : theTests(types);
Expand Down Expand Up @@ -87,11 +108,13 @@ export function testTypeImpl({
expect(type.apply(undefined, [input])).toEqual(output);
expect(type.bind(undefined, input)()).toEqual(output);
expect(type.call(undefined, input)).toEqual(output);
expect(standardValidate(type, input)).toEqual({ value: output });
});

invalidConversions &&
test.each(invalidConversions)('will not convert: %p', (value, message) => {
test.each(invalidConversions)('will not convert: %p', (value, message, issues = defaultIssues(message)) => {
expect(() => type(value)).toThrowWithMessage(ValidationErrorForTest, Array.isArray(message) ? message.join('\n') : message);
expect(standardValidate(type, value)).toEqual({ issues });
});
}
}
Expand Down Expand Up @@ -209,3 +232,40 @@ class CreateExampleVisitor implements Visitor<unknown> {
function hasExample<T>(obj: BaseTypeImpl<T>): obj is BaseTypeImpl<T> & { example: T } {
return 'example' in obj;
}

/**
* Helper function around StandardSchema validation interface to incorporate it in the existing conversion tests.
*
* Note that Skunkteam Types has a distinction between checking if an input conforms to a schema (Type) as-is (`.is()`, `.check()`) vs
* validating if an input can be parsed and converted into the schema (`.construct()`). This makes it non-trivial to fully incorporate
* the StandardSchema interface into the existing test-suite.
*/
function standardValidate<T>(schema: StandardSchemaV1<T>, input: unknown): StandardSchemaV1.Result<T> {
const result = schema['~standard'].validate(input);
if (result instanceof Promise) throw new TypeError('No asynchronous type validation in Skunkteam Types');
return result;
}

function defaultIssues(input: string | RegExp | string[]): StandardSchemaV1.Issue[] {
const message = Array.isArray(input) ? input.join('\n') : typeof input === 'string' ? input : expect.stringMatching(input);
// Perform some string parsing on the error to guess what the standard schema issue should look like. This is just to prevent having to
// configure a lot of expectations, but does not cover every possibility. Especially multiple errors will have to be stated explicitly.
//
// Things we do here:
// - Remove the common header that says "error in {optional context} [TheTypeName]:", because that should not be part of the issues
// list.
// - Try to guess the path if any.
const hasPath = typeof message === 'string' && /^error in [^[]*\[.*?\](?: at [^<]*<(?<path>[^>]+)>)?: (?<message>[^]*)$/.exec(message);
if (hasPath) {
assert(hasPath.groups);
return [
{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript is wrong here
path: hasPath.groups.path?.split('.').map(key => (/^\[\d+\]$/.test(key) ? +key.slice(1, -1) : key)),
message: hasPath.groups.message,
},
];
}
// if (typeof message === 'string') message = message.replace(/^error in .*\[.*\]: /, '');
return [{ message }];
}
Loading

0 comments on commit 36a3554

Please sign in to comment.