diff --git a/package.json b/package.json index af63398..249c235 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@types/vali-date": "^1.0.0", "ava": "^2.0.0", "del-cli": "^3.0.1", + "expect-type": "^0.11.0", "nyc": "^15.1.0", "ts-node": "^9.0.0", "typedoc": "^0.19.2", diff --git a/readme.md b/readme.md index 3f13092..8917e01 100644 --- a/readme.md +++ b/readme.md @@ -69,7 +69,17 @@ ow(unicorn, ow.object.exactShape({ [Complete API documentation](https://sindresorhus.com/ow) -Ow does not currently include TypeScript type guards, but we do [plan to include type assertions](https://github.com/sindresorhus/ow/issues/159). +Ow includes TypeScript type guards, so using it will narrow the type of previously-unknown values. + +```ts +function (input: unknown) { + input.slice(0, 3) // Error, Property 'slice' does not exist on type 'unknown' + + ow(input, ow.string) + + input.slice(0, 3) // OK +} +``` ### ow(value, predicate) diff --git a/source/index.ts b/source/index.ts index 1452748..1478416 100644 --- a/source/index.ts +++ b/source/index.ts @@ -34,7 +34,7 @@ export interface Ow extends Modifiers, Predicates { @param value - Value to test. @param predicate - Predicate to test against. */ - (value: T, predicate: BasePredicate): void; + (value: unknown, predicate: BasePredicate): asserts value is T; /** Test if `value` matches the provided `predicate`. Throws an `ArgumentError` with the specified `label` if the test fails. @@ -43,7 +43,7 @@ export interface Ow extends Modifiers, Predicates { @param label - Label which should be used in error messages. @param predicate - Predicate to test against. */ - (value: T, label: string, predicate: BasePredicate): void; + (value: unknown, label: string, predicate: BasePredicate): asserts value is T; } /** @@ -103,7 +103,12 @@ Object.defineProperties(ow, { } }); -export default predicates(modifiers(ow)) as Ow; +// Can't use `export default predicates(modifiers(ow)) as Ow` because the variable needs a type annotation to avoid a compiler error when used: +// Assertions require every name in the call target to be declared with an explicit type annotation.ts(2775) +// See https://github.com/microsoft/TypeScript/issues/36931 for more details. +const _ow: Ow = predicates(modifiers(ow)) as Ow; + +export default _ow; export {BasePredicate, Predicate}; diff --git a/source/modifiers.ts b/source/modifiers.ts index f1ee4e0..f812cd3 100644 --- a/source/modifiers.ts +++ b/source/modifiers.ts @@ -1,10 +1,17 @@ +import {BasePredicate} from '.'; import predicates, {Predicates} from './predicates'; +type Optionalify

= P extends BasePredicate + ? P & BasePredicate + : P; + export interface Modifiers { /** Make the following predicate optional so it doesn't fail when the value is `undefined`. */ - readonly optional: Predicates; + readonly optional: { + [K in keyof Predicates]: Optionalify + }; } export default (object: T): T & Modifiers => { diff --git a/source/predicates/array.ts b/source/predicates/array.ts index 382c223..2c5bc08 100644 --- a/source/predicates/array.ts +++ b/source/predicates/array.ts @@ -139,9 +139,10 @@ export class ArrayPredicate extends Predicate { ow(['a', 1], ow.array.ofType(ow.any(ow.string, ow.number))); ``` */ - ofType

>(predicate: P) { + ofType(predicate: BasePredicate): ArrayPredicate { let error: string; + // TODO [typescript@>=5] If higher-kinded types are supported natively by typescript, refactor `addValidator` to use them to avoid the usage of `any`. Otherwise, bump or remove this TODO. return this.addValidator({ message: (_, label) => `(${label}) ${error}`, validator: value => { @@ -156,6 +157,6 @@ export class ArrayPredicate extends Predicate { return false; } } - }); + }) as ArrayPredicate; } } diff --git a/source/predicates/object.ts b/source/predicates/object.ts index e53c946..f504be3 100644 --- a/source/predicates/object.ts +++ b/source/predicates/object.ts @@ -5,13 +5,13 @@ import isEqual = require('lodash.isequal'); import hasItems from '../utils/has-items'; import ofType from '../utils/of-type'; import ofTypeDeep from '../utils/of-type-deep'; -import {partial, exact, Shape} from '../utils/match-shape'; +import {partial, exact, Shape, TypeOfShape} from '../utils/match-shape'; import {Predicate, PredicateOptions} from './predicate'; import {BasePredicate} from './base-predicate'; export {Shape}; -export class ObjectPredicate extends Predicate { +export class ObjectPredicate extends Predicate { /** @hidden */ @@ -155,12 +155,12 @@ export class ObjectPredicate extends Predicate { })); ``` */ - partialShape(shape: Shape) { + partialShape(shape: S): ObjectPredicate> { return this.addValidator({ // TODO: Improve this when message handling becomes smarter message: (_, label, message) => `${message.replace('Expected', 'Expected property')} in ${label}`, validator: object => partial(object, shape) - }); + }) as unknown as ObjectPredicate>; } /** @@ -179,11 +179,12 @@ export class ObjectPredicate extends Predicate { })); ``` */ - exactShape(shape: Shape) { + exactShape(shape: S): ObjectPredicate> { + // TODO [typescript@>=5] If higher-kinded types are supported natively by typescript, refactor `addValidator` to use them to avoid the usage of `any`. Otherwise, bump or remove this TODO. return this.addValidator({ // TODO: Improve this when message handling becomes smarter message: (_, label, message) => `${message.replace('Expected', 'Expected property')} in ${label}`, validator: object => exact(object, shape) - }); + }) as ObjectPredicate; } } diff --git a/source/predicates/predicate.ts b/source/predicates/predicate.ts index 95c8f71..b0f447c 100644 --- a/source/predicates/predicate.ts +++ b/source/predicates/predicate.ts @@ -95,15 +95,13 @@ export class Predicate implements BasePredicate { /** @hidden */ - [testSymbol](value: T | undefined, main: Main, label: string | Function): asserts value { + [testSymbol](value: T, main: Main, label: string | Function): asserts value is T { for (const {validator, message} of this.context.validators) { if (this.options.optional === true && value === undefined) { continue; } - const knownValue = value!; - - const result = validator(knownValue); + const result = validator(value); if (result === true) { continue; @@ -120,7 +118,7 @@ export class Predicate implements BasePredicate { this.type; // TODO: Modify the stack output to show the original `ow()` call instead of this `throw` statement - throw new ArgumentError(message(knownValue, label2, result), main); + throw new ArgumentError(message(value, label2, result), main); } } diff --git a/source/utils/match-shape.ts b/source/utils/match-shape.ts index 2a64912..33d3ba4 100644 --- a/source/utils/match-shape.ts +++ b/source/utils/match-shape.ts @@ -8,6 +8,32 @@ export interface Shape { [key: string]: BasePredicate | Shape; } +/** +Extracts a regular type from a shape definition. + +@example +``` +const myShape = { + foo: ow.string, + bar: { + baz: ow.boolean + } +} + +type X = TypeOfShape // {foo: string; bar: {baz: boolean}} +``` + +This is used in the `ow.object.partialShape(…)` and `ow.object.exactShape(…)` functions. +*/ +export type TypeOfShape = + S extends BasePredicate + ? X + : S extends Shape + ? { + [K in keyof S]: TypeOfShape + } + : never; + /** Test if the `object` matches the `shape` partially. diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 0000000..678cfa8 --- /dev/null +++ b/test/types.ts @@ -0,0 +1,146 @@ +import test from 'ava'; +import {ExpectTypeOf, expectTypeOf} from 'expect-type'; +import {TypedArray} from 'type-fest'; +import ow, {BasePredicate} from '../source'; + +test('type-level tests', t => { + t.is(typeof typeTests, 'function'); +}); + +// These tests will fail at compile-time, not runtime. +// The function isn't actually called, it's just a way of declaring scoped type-level tests +// that doesn't make the compiler angry about unused variables. +function typeTests(value: unknown) { + return [ + () => { + expectTypeOf(value).toBeUnknown(); + + ow(value, ow.string); + + expectTypeOf(value).toBeString(); + expectTypeOf(value).not.toBeNever(); + + ow(value, ow.boolean); + + expectTypeOf(value).toBeNever(); // Can't be a string and a boolean! + }, + + () => { + ow(value, 'my-label', ow.number); + + expectTypeOf(value).toBeNumber(); + }, + + () => { + ow(value, ow.string.maxLength(7)); + + expectTypeOf(value).toBeString(); + }, + + () => { + ow(value, ow.optional.string); + + expectTypeOf(value).toEqualTypeOf(); + }, + + () => { + ow(value, ow.iterable); + + expectTypeOf(value).toEqualTypeOf>(); + + ow(value, ow.array); + + expectTypeOf(value).toEqualTypeOf(); + + ow(value, ow.array.ofType(ow.string)); + + expectTypeOf(value).toEqualTypeOf(); + }, + + () => { + ow(value, ow.array.ofType(ow.any(ow.string, ow.number, ow.boolean, ow.nullOrUndefined))); + + expectTypeOf(value).toEqualTypeOf>(); + }, + + () => { + ow(value, ow.object); + + expectTypeOf(value).toBeObject(); + + ow(value, ow.object.partialShape({ + foo: ow.string + })); + + expectTypeOf(value).toEqualTypeOf<{ + foo: string; + }>(); + + ow(value, ow.object.exactShape({ + foo: ow.string, + bar: ow.object.exactShape({ + baz: ow.number + }) + })); + + expectTypeOf(value).toEqualTypeOf<{ + foo: string; + bar: {baz: number}; + }>(); + }, + + () => { + // To make sure all validators are mapped to the correct type, create a `Tests` type which requires that + // every property of `ow` has its type-mapping explicitly tested. If more properties are added this will + // fail until a type assertion is added below. + + type AssertionProps = Exclude; + + type Tests = { + [K in AssertionProps]: + typeof ow[K] extends BasePredicate + ? (type: ExpectTypeOf) => void + : never + }; + + const tests: Tests = { + array: expect => expect.toBeArray(), + arrayBuffer: expect => expect.toEqualTypeOf(), + boolean: expect => expect.toBeBoolean(), + buffer: expect => expect.toEqualTypeOf(), + dataView: expect => expect.toEqualTypeOf(), + date: expect => expect.toEqualTypeOf(), + error: expect => expect.toEqualTypeOf(), + float32Array: expect => expect.toEqualTypeOf(), + float64Array: expect => expect.toEqualTypeOf(), + function: expect => expect.toEqualTypeOf(), + int16Array: expect => expect.toEqualTypeOf(), + int32Array: expect => expect.toEqualTypeOf(), + int8Array: expect => expect.toEqualTypeOf(), + iterable: expect => expect.toEqualTypeOf>(), + map: expect => expect.toEqualTypeOf>(), + nan: expect => expect.toEqualTypeOf(Number.NaN), + null: expect => expect.toEqualTypeOf(), + nullOrUndefined: expect => expect.toEqualTypeOf(), + number: expect => expect.toBeNumber(), + object: expect => expect.toBeObject(), + promise: expect => expect.toEqualTypeOf>(), + regExp: expect => expect.toEqualTypeOf(), + set: expect => expect.toEqualTypeOf>(), + sharedArrayBuffer: expect => expect.toEqualTypeOf(), + string: expect => expect.toBeString(), + symbol: expect => expect.toEqualTypeOf(), + typedArray: expect => expect.toEqualTypeOf(), + uint16Array: expect => expect.toEqualTypeOf(), + uint32Array: expect => expect.toEqualTypeOf(), + uint8Array: expect => expect.toEqualTypeOf(), + uint8ClampedArray: expect => expect.toEqualTypeOf(), + undefined: expect => expect.toEqualTypeOf(), + weakMap: expect => expect.toEqualTypeOf>(), + weakSet: expect => expect.toEqualTypeOf>() + }; + + return tests; + } + ]; +}