From e06f267414d251ab6a0612517830c3a9e6b0f506 Mon Sep 17 00:00:00 2001 From: Paco van der Linden Date: Thu, 13 Oct 2022 14:02:00 +0200 Subject: [PATCH 1/2] feat: type-guards are bound to the type-instance --- .vscode/settings.json | 3 ++- etc/types.api.md | 4 ++-- jest.config.js | 8 ++++---- markdown/types.basetypeimpl.check.md | 14 ++------------ markdown/types.basetypeimpl.is.md | 14 ++------------ markdown/types.basetypeimpl.md | 20 ++++++++++---------- src/base-type.test.ts | 22 ++++++++++++++++++++++ src/base-type.ts | 23 +++++++++++++++++------ 8 files changed, 61 insertions(+), 47 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index eb81e21..e0555ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,6 @@ "etc/**/*": true, "markdown/**/*": true, "node_modules/**/*": true - } + }, + "task.allowAutomaticTasks": "on" } diff --git a/etc/types.api.md b/etc/types.api.md index b4d0538..d0c633e 100644 --- a/etc/types.api.md +++ b/etc/types.api.md @@ -62,7 +62,7 @@ export abstract class BaseTypeImpl implements get autoCastAll(): this; protected autoCaster?(this: BaseTypeImpl, value: unknown): unknown; abstract readonly basicType: BasicType | 'mixed'; - check(input: unknown): ResultType; + get check(): (this: void, input: unknown) => ResultType; protected combineConfig(oldConfig: TypeConfig, newConfig: TypeConfig): TypeConfig; construct(input: unknown): ResultType; // (undocumented) @@ -70,7 +70,7 @@ export abstract class BaseTypeImpl implements protected createResult(input: unknown, result: unknown, validatorResult: ValidationResult): Result; readonly enumerableLiteralDomain?: Iterable; extendWith(factory: (type: this) => E): this & E; - is(input: unknown): input is ResultType; + get is(): (this: void, input: Input) => input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never; literal(input: DeepUnbranded): ResultType; abstract readonly name: string; or(_other: BaseTypeImpl): Type; diff --git a/jest.config.js b/jest.config.js index f83b194..6a266f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,10 +12,10 @@ const config = { restoreMocks: true, coverageThreshold: { global: { - statements: 99, - branches: 96, - functions: 99, - lines: 99, + statements: -4, + branches: -21, + functions: -2, + lines: 100, }, }, moduleNameMapper: { '^(.*)\\.js$': ['$1.js', '$1.ts'] }, diff --git a/markdown/types.basetypeimpl.check.md b/markdown/types.basetypeimpl.check.md index 1715571..cdd7ac3 100644 --- a/markdown/types.basetypeimpl.check.md +++ b/markdown/types.basetypeimpl.check.md @@ -2,26 +2,16 @@ [Home](./index.md) > [@skunkteam/types](./types.md) > [BaseTypeImpl](./types.basetypeimpl.md) > [check](./types.basetypeimpl.check.md) -## BaseTypeImpl.check() method +## BaseTypeImpl.check property Asserts that a value conforms to this Type and returns the input as is, if it does. **Signature:** ```typescript -check(input: unknown): ResultType; +get check(): (this: void, input: unknown) => ResultType; ``` -## Parameters - -| Parameter | Type | Description | -| --------- | ------- | ------------------ | -| input | unknown | the value to check | - -**Returns:** - -ResultType - ## Remarks When given a value that does not conform to the Type, throws an exception. diff --git a/markdown/types.basetypeimpl.is.md b/markdown/types.basetypeimpl.is.md index e7f41d9..633dcd0 100644 --- a/markdown/types.basetypeimpl.is.md +++ b/markdown/types.basetypeimpl.is.md @@ -2,22 +2,12 @@ [Home](./index.md) > [@skunkteam/types](./types.md) > [BaseTypeImpl](./types.basetypeimpl.md) > [is](./types.basetypeimpl.is.md) -## BaseTypeImpl.is() method +## BaseTypeImpl.is property A type guard for this Type. **Signature:** ```typescript -is(input: unknown): input is ResultType; +get is(): (this: void, input: Input) => input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never; ``` - -## Parameters - -| Parameter | Type | Description | -| --------- | ------- | ----------- | -| input | unknown | | - -**Returns:** - -input is ResultType diff --git a/markdown/types.basetypeimpl.md b/markdown/types.basetypeimpl.md index 7cf8ac4..784cf15 100644 --- a/markdown/types.basetypeimpl.md +++ b/markdown/types.basetypeimpl.md @@ -20,14 +20,16 @@ All type-implementations must extend this base class. Use [createType()](./types ## Properties -| Property | Modifiers | Type | Description | -| --------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | -| [autoCast](./types.basetypeimpl.autocast.md) | readonly | this | The same type, but with an auto-casting default parser installed. | -| [autoCastAll](./types.basetypeimpl.autocastall.md) | readonly | this | Create a recursive autocasting version of the current type. | -| [basicType](./types.basetypeimpl.basictype.md) |

abstract

readonly

| [BasicType](./types.basictype.md) \| 'mixed' | The kind of values this type validates. | -| [enumerableLiteralDomain?](./types.basetypeimpl.enumerableliteraldomain.md) | readonly | Iterable<[LiteralValue](./types.literalvalue.md)> | _(Optional)_ The set of valid literals if enumerable. | -| [name](./types.basetypeimpl.name.md) |

abstract

readonly

| string | The name of the Type. | -| [typeConfig](./types.basetypeimpl.typeconfig.md) |

abstract

readonly

| TypeConfig | Extra information that is made available by this Type for runtime analysis. | +| Property | Modifiers | Type | Description | +| --------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| [autoCast](./types.basetypeimpl.autocast.md) | readonly | this | The same type, but with an auto-casting default parser installed. | +| [autoCastAll](./types.basetypeimpl.autocastall.md) | readonly | this | Create a recursive autocasting version of the current type. | +| [basicType](./types.basetypeimpl.basictype.md) |

abstract

readonly

| [BasicType](./types.basictype.md) \| 'mixed' | The kind of values this type validates. | +| [check](./types.basetypeimpl.check.md) | readonly | (this: void, input: unknown) => ResultType | Asserts that a value conforms to this Type and returns the input as is, if it does. | +| [enumerableLiteralDomain?](./types.basetypeimpl.enumerableliteraldomain.md) | readonly | Iterable<[LiteralValue](./types.literalvalue.md)> | _(Optional)_ The set of valid literals if enumerable. | +| [is](./types.basetypeimpl.is.md) | readonly | <Input>(this: void, input: Input) => input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never | A type guard for this Type. | +| [name](./types.basetypeimpl.name.md) |

abstract

readonly

| string | The name of the Type. | +| [typeConfig](./types.basetypeimpl.typeconfig.md) |

abstract

readonly

| TypeConfig | Extra information that is made available by this Type for runtime analysis. | ## Methods @@ -37,13 +39,11 @@ All type-implementations must extend this base class. Use [createType()](./types | [andThen(fn)](./types.basetypeimpl.andthen.md) | | Create a function with validated input. | | [assert(input)](./types.basetypeimpl.assert.md) | | Verifies that a value conforms to this Type. | | [autoCaster(this, value)?](./types.basetypeimpl.autocaster.md) | protected | _(Optional)_ The logic that is used in the autocasting version of the current type. | -| [check(input)](./types.basetypeimpl.check.md) | | Asserts that a value conforms to this Type and returns the input as is, if it does. | | [combineConfig(oldConfig, newConfig)](./types.basetypeimpl.combineconfig.md) | protected | Combine two config values into a new value. | | [construct(input)](./types.basetypeimpl.construct.md) | | Calls any registered parsers or auto-caster, verifies that the resulting value conforms to this Type and returns it if it does. | | [createAutoCastAllType()](./types.basetypeimpl.createautocastalltype.md) | protected | | | [createResult(input, result, validatorResult)](./types.basetypeimpl.createresult.md) | protected | Create a Result based on the given [ValidationResult](./types.validationresult.md). | | [extendWith(factory)](./types.basetypeimpl.extendwith.md) | | Extend the Type with additional static methods and properties. | -| [is(input)](./types.basetypeimpl.is.md) | | A type guard for this Type. | | [literal(input)](./types.basetypeimpl.literal.md) | | Calls any registered parsers or auto-caster, verifies that the resulting value conforms to this Type and returns it if it does. | | [or(\_other)](./types.basetypeimpl.or.md) | | Union this Type with another Type. | | [typeParser(input, options)?](./types.basetypeimpl.typeparser.md) | protected | _(Optional)_ Optional pre-processing parser. | diff --git a/src/base-type.test.ts b/src/base-type.test.ts index f38c646..2b9a012 100644 --- a/src/base-type.test.ts +++ b/src/base-type.test.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import { BaseTypeImpl } from './base-type.js'; import type { The } from './interfaces.js'; import { assignableTo, testTypes } from './testutils.js'; @@ -26,6 +27,27 @@ describe(BaseTypeImpl, () => { expect(value).toEqual({ key: 'value' }); } + const array = [value, value]; + if (array.every(string.is)) { + assignableTo<'a string'[]>(array); + assignableTo(['a string']); + expect(array).toEqual(['a string', 'a string']); + } else if (array.every(number.is)) { + assignableTo<123[]>(array); + assignableTo([123]); + expect(array).toEqual([123, 123]); + } else if (array.every(boolean.is)) { + assignableTo(array); + assignableTo([false]); + expect(array).toEqual([false, false]); + } else if (array.every(unknownRecord.is)) { + assignableTo<{ key: 'value' }[]>(array); + assignableTo([{ key: 'value' }]); + expect(array).toEqual([{ key: 'value' }, { key: 'value' }]); + } else { + assert.fail('should have matched one of the other predicates'); + } + testTypes(() => { string.assert(value); assignableTo<'a string'>(value); diff --git a/src/base-type.ts b/src/base-type.ts index a2dace3..f11c178 100644 --- a/src/base-type.ts +++ b/src/base-type.ts @@ -96,6 +96,8 @@ export abstract class BaseTypeImpl implements private readonly _instanceCache: { autoCast?: BaseTypeImpl; autoCastAll?: BaseTypeImpl; + boundCheck?: BaseTypeImpl['check']; + boundIs?: BaseTypeImpl['is']; } = {}; /** @@ -192,9 +194,11 @@ export abstract class BaseTypeImpl implements * * @param input - the value to check */ - check(input: unknown): ResultType { - this.assert(input); - return input; + get check(): (this: void, input: unknown) => ResultType { + return (this._instanceCache.boundCheck ??= input => { + this.assert(input); + return input; + }); } /** @@ -272,8 +276,15 @@ export abstract class BaseTypeImpl implements /** * A type guard for this Type. */ - is(input: unknown): input is ResultType { - return this.validate(input, { mode: 'check' }).ok; + get is(): ( + this: void, + input: Input, + ) => input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never { + this._instanceCache.boundIs ??= ( + input: Input, + ): input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never => + this.validate(input, { mode: 'check' }).ok; + return this._instanceCache.boundIs; } /** @@ -381,7 +392,7 @@ export abstract class BaseTypeImpl implements if (!baseResult.ok) { return type.createResult(input, undefined, baseResult.details); } - const tryResult = ValidationError.try( + const tryResult = ValidationError.try( { type, input }, // if no name is given, then default to the message "additional validation failed" () => validation(baseResult.value, options) || 'additional validation failed', From d66f80a59e771152f3fe3a03347a2466825b1a8c Mon Sep 17 00:00:00 2001 From: Paco van der Linden Date: Wed, 23 Aug 2023 09:20:19 +0200 Subject: [PATCH 2/2] improve typeguard typings --- src/base-type.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++++- src/base-type.ts | 11 ++--- src/interfaces.ts | 24 +++++++++++ 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/base-type.test.ts b/src/base-type.test.ts index 2b9a012..1de4f4b 100644 --- a/src/base-type.test.ts +++ b/src/base-type.test.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { BaseTypeImpl } from './base-type.js'; import type { The } from './interfaces.js'; import { assignableTo, testTypes } from './testutils.js'; -import { boolean, int, number, object, pattern, string, unknownRecord } from './types/index.js'; +import { boolean, int, literal, number, object, pattern, string, undefinedType, unknownRecord } from './types/index.js'; describe(BaseTypeImpl, () => { test.each(['a string', 123, false, { key: 'value' }] as const)('guard value: %p', value => { @@ -73,6 +73,97 @@ describe(BaseTypeImpl, () => { }); }); + test('guard value: unknown', () => { + const value = undefined as unknown; + if (string.is(value)) { + assignableTo(value); + assignableTo('a string'); + } + if (number.is(value)) { + assignableTo(value); + assignableTo(123); + } + if (boolean.is(value)) { + assignableTo(value); + assignableTo(false); + } + if (unknownRecord.is(value)) { + assignableTo(value); + assignableTo({ key: 'value' }); + } + expect(undefinedType.is(value)).toBeTrue(); + if (undefinedType.is(value)) { + assignableTo(value); + assignableTo(undefined); + } + + const array = [value, value]; + if (array.every(string.is)) { + assignableTo(array); + assignableTo(['a string']); + } else if (array.every(number.is)) { + assignableTo(array); + assignableTo([123]); + } else if (array.every(boolean.is)) { + assignableTo(array); + assignableTo([false]); + } else if (array.every(unknownRecord.is)) { + assignableTo(array); + assignableTo([{ key: 'value' }]); + } else if (array.every(undefinedType.is)) { + assignableTo(array); + assignableTo([undefined]); + } else { + assert.fail('should have matched the last predicate'); + } + + testTypes(() => { + string.assert(value); + assignableTo(value); + assignableTo('a string'); + }); + + testTypes(() => { + number.assert(value); + assignableTo(value); + assignableTo(123); + }); + + testTypes(() => { + boolean.assert(value); + assignableTo(value); + assignableTo(false); + }); + + testTypes(() => { + unknownRecord.assert(value); + assignableTo(value); + assignableTo({ key: 'value' }); + }); + }); + + test('guard objects', () => { + type TheType = The; + const TheType = object('TheType', { narrow: literal('value'), wide: string }); + + type UnionOfObjects = + | { narrow: number; wide: string } // not compatible because of narrow: number + | { narrow: string; wide: 'sneaky narrow' } // compatible if narrow happens to be `'value'` + | { something: 'else' }; // compatible if narrow and wide are present, we don't know. + const obj = { narrow: 'value', wide: 'sneaky narrow' } as UnionOfObjects; + if (TheType.is(obj)) { + assignableTo<{ narrow: 'value'; wide: 'sneaky narrow' } | { something: 'else'; narrow: 'value'; wide: string }>(obj); + assignableTo({ narrow: 'value', wide: 'sneaky narrow' }); + assignableTo({ something: 'else', narrow: 'value', wide: 'something else' }); + } + + const value = { narrow: 'value', wide: 'a literal' as const }; + if (TheType.is(value)) { + assignableTo<{ narrow: 'value'; wide: 'a literal' }>(value); + assignableTo({ narrow: 'value', wide: 'a literal' }); + } + }); + test('#literal', () => { type NumericString = The; const NumericString = pattern('NumericString', /^\d+$/); diff --git a/src/base-type.ts b/src/base-type.ts index f11c178..bf0af1a 100644 --- a/src/base-type.ts +++ b/src/base-type.ts @@ -10,6 +10,8 @@ import type { PropertiesInfo, Result, Type, + TypeguardFor, + TypeguardResult, TypeImpl, TypeLink, ValidationOptions, @@ -276,13 +278,8 @@ export abstract class BaseTypeImpl implements /** * A type guard for this Type. */ - get is(): ( - this: void, - input: Input, - ) => input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never { - this._instanceCache.boundIs ??= ( - input: Input, - ): input is unknown extends Input ? ResultType & Input : Input extends ResultType ? Input : never => + get is(): TypeguardFor { + this._instanceCache.boundIs ??= (input: Input): input is TypeguardResult => this.validate(input, { mode: 'check' }).ok; return this._instanceCache.boundIs; } diff --git a/src/interfaces.ts b/src/interfaces.ts index f4407bb..1b19fcf 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -278,6 +278,8 @@ export interface ParserOptions { /** The supported types of literals. */ export type LiteralValue = string | number | boolean | null | undefined | void; +export type Primitive = LiteralValue | bigint | symbol; + /** * Basic categories of types. * @@ -296,6 +298,28 @@ export type BasicType = 'string' | 'number' | 'bigint' | 'boolean' | 'function' // eslint-disable-next-line @typescript-eslint/ban-types export type MergeIntersection = T extends Record ? { [P in keyof T]: T[P] } & {} : T; +/** + * The type of the type-guard that comes with each Type (the #is method). + * + * @remarks + * Normally a typeguard wouldn't need the Input type-parameter, but this is needed for compatibility with `Array#every`. That method + * requires the use of this type-param. + */ +export type TypeguardFor = (this: void, input: Input) => input is TypeguardResult; + +/** + * The resulting type of a typeguard based on the `ResultType` of the Type and the given `Input`. + */ +export type TypeguardResult = + // TypeScript does not allow us to define the TypeGuardResult based on ResultType (in our case), so we need to base it on the Input itself. + // If the input is unknown, we can fall back to basic typeguard result which is by far the best scenario. + unknown extends Input + ? Input & ResultType // <-- this is the best scenario, TypeScript needs to see `Input &` here, but `Input` is `unknown`, so it doesn't matter + : // If Input is not unknown, we support two scenarios. It will never be really type-safe in all cases, but that is typeguards for you + [Extract] extends [never] // scenario 1: if we cannot identify elements of a union that look compatible... + ? Input & ResultType // ... then let TypeScript try to figure it out, which often works, ... + : Extract; // ... otherwise pick those elements of the union and hope for the best. + /** * An Array with at least one element. */