Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading