Skip to content

Commit

Permalink
feat: Introduce StandardSchemaV1 interface in Skunkteam Types
Browse files Browse the repository at this point in the history
Hooked into the existing Valid/Invalid Conversion tests.
  • Loading branch information
untio11 committed Feb 12, 2025
1 parent 7da4ed3 commit a1cafb3
Show file tree
Hide file tree
Showing 6 changed files with 74 additions and 10 deletions.
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
20 changes: 19 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 @@ -43,7 +45,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 @@ -119,6 +123,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 @@ -495,6 +500,19 @@ export abstract class BaseTypeImpl<ResultType, TypeConfig = unknown> implements
protected combineConfig(oldConfig: TypeConfig, newConfig: TypeConfig): TypeConfig {
return { ...oldConfig, ...newConfig };
}

get ['~standard'](): StandardSchemaV1.Props<unknown, ResultType> {
return (this._instanceCache.standardSchema ??= {
version: 1,
vendor: 'skunkteam',
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
33 changes: 25 additions & 8 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 All @@ -17,21 +18,37 @@ export function reportError(root: Failure, level = -1, omitInput?: boolean): str
details.sort(detailSorter);

if (details.length === 1 && !('parserInput' in root)) {
const [detail] = details;
const ctx = detail.context ? `${detail.context} of ` : '';
const msg = detail.path
? `error in [${root.type.name}] at ${ctx}<${printPath(detail.path)}>: `
: root.type.name !== detail.type.name || prependWithTypeName(detail)
? `error in ${ctx}[${root.type.name}]: `
: '';
return msg + detailMessage(detail, childLevel);
return singleDetailMessage(root.type.name, details[0], childLevel);
}

let msg = `errors in [${root.type.name}]:`;
'parserInput' in root && (msg += reportInput(root, childLevel));
return msg + reportDetails(details, childLevel);
}

/** Minor variation of `reportError` that maps the top-level failure details as individual issues in the StandardSchema format */
export function mapFailureToStandardIssues(root: Failure): readonly StandardSchemaV1.Issue[] {
if (root.details.length === 1 && !('parserInput' in root)) {
const [detail] = root.details;
return [{ message: singleDetailMessage(root.type.name, detail, 0), path: detail.path }];
}

const msg = `errors in [${root.type.name}]:${'parserInput' in root ? ` (${maybePrintInputValue(root, '')})` : ''}`;
return [{ message: msg }].concat(
root.details.sort(detailSorter).map(detail => ({ message: detailMessageWithContext(detail, 0), path: detail.path })),
);
}

function singleDetailMessage(typeName: string, detail: FailureDetails, childLevel: number): string {
const ctx = detail.context ? `${detail.context} of ` : '';
const msg = detail.path
? `error in [${typeName}] at ${ctx}<${printPath(detail.path)}>: `
: typeName !== detail.type.name || prependWithTypeName(detail)
? `error in ${ctx}[${typeName}]: `
: '';
return msg + detailMessage(detail, childLevel);
}

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
22 changes: 22 additions & 0 deletions src/testutils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */

import { StandardSchemaV1 } from '@standard-schema/spec';
import type { BaseObjectLikeTypeImpl, BaseTypeImpl } from './base-type';
import type { BasicType, LiteralValue, NumberTypeConfig, OneOrMore, StringTypeConfig, Type, Visitor } from './interfaces';
import type { ArrayType, KeyofType, LiteralType, RecordType, UnionType } from './types';
Expand Down Expand Up @@ -87,11 +88,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(output);
});

invalidConversions &&
test.each(invalidConversions)('will not convert: %p', (value, message) => {
expect(() => type(value)).toThrowWithMessage(ValidationErrorForTest, Array.isArray(message) ? message.join('\n') : message);
expect(() => standardValidate(type, value)).toThrow();
});
}
}
Expand Down Expand Up @@ -205,3 +208,22 @@ 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 extends StandardSchemaV1>(schema: T, input: StandardSchemaV1.InferInput<T>): StandardSchemaV1.InferOutput<T> {
const result = schema['~standard'].validate(input);
if (result instanceof Promise) throw new TypeError('No asynchronous type validation in Skunkteam Types');

// if the `issues` field exists, the validation failed
if (result.issues) {
throw new Error(result.issues.map(issue => issue.message).join('\n'));
}

return result.value;
}

0 comments on commit a1cafb3

Please sign in to comment.