From 85c89925b6407972121200f91fa122ace937973e Mon Sep 17 00:00:00 2001 From: Bjorn Stromberg Date: Wed, 9 Aug 2023 19:49:06 +0800 Subject: [PATCH] Give better assertion messages for `assert.all` and `assert.any` (#193) --- source/index.ts | 146 ++++++++++++++++++++++++++++++++++++++++++------ test/test.ts | 39 ++++++++++--- tsconfig.json | 8 ++- 3 files changed, 166 insertions(+), 27 deletions(-) diff --git a/source/index.ts b/source/index.ts index 0239440..137dd40 100644 --- a/source/index.ts +++ b/source/index.ts @@ -794,10 +794,18 @@ function typeErrorMessage(description: AssertionTypeDescription, value: unknown) return `Expected value which is \`${description}\`, received value of type \`${is(value)}\`.`; } -function typeErrorMessageMultipleValue(description: AssertionTypeDescription, values: unknown[]): string { +function unique(values: T[]): T[] { // eslint-disable-next-line unicorn/prefer-spread - const valueTypes = Array.from(new Set(values.map(singleValue => `\`${is(singleValue)}\``))).join(', '); - return `Expected value which is \`${description}\`, received values of types ${valueTypes}.`; + return Array.from(new Set(values)); +} + +const andFormatter = new Intl.ListFormat('en', {style: 'long', type: 'conjunction'}); +const orFormatter = new Intl.ListFormat('en', {style: 'long', type: 'disjunction'}); + +function typeErrorMessageMultipleValues(expectedType: AssertionTypeDescription | AssertionTypeDescription[], values: unknown[]): string { + const uniqueExpectedTypes = unique((isArray(expectedType) ? expectedType : [expectedType]).map(value => `\`${value}\``)); + const uniqueValueTypes = unique(values.map(value => `\`${is(value)}\``)); + return `Expected values which are ${orFormatter.format(uniqueExpectedTypes)}. Received values of type${uniqueValueTypes.length > 1 ? 's' : ''} ${andFormatter.format(uniqueValueTypes)}.`; } // Type assertions have to be declared with an explicit type. @@ -1011,15 +1019,119 @@ export const assert: Assert = { whitespaceString: assertWhitespaceString, }; +const methodTypeMap = { + isArray: 'Array', + isArrayBuffer: 'ArrayBuffer', + isArrayLike: 'array-like', + isAsyncFunction: 'AsyncFunction', + isAsyncGenerator: 'AsyncGenerator', + isAsyncGeneratorFunction: 'AsyncGeneratorFunction', + isAsyncIterable: 'AsyncIterable', + isBigint: 'bigint', + isBigInt64Array: 'BigInt64Array', + isBigUint64Array: 'BigUint64Array', + isBlob: 'Blob', + isBoolean: 'boolean', + isBoundFunction: 'Function', + isBuffer: 'Buffer', + isClass: 'Class', + isDataView: 'DataView', + isDate: 'Date', + isDirectInstanceOf: 'T', + isDomElement: 'HTMLElement', + isEmptyArray: 'empty array', + isEmptyMap: 'empty map', + isEmptyObject: 'empty object', + isEmptySet: 'empty set', + isEmptyString: 'empty string', + isEmptyStringOrWhitespace: 'empty string or whitespace', + isEnumCase: 'EnumCase', + isError: 'Error', + isEvenInteger: 'even integer', + isFalsy: 'falsy', + isFloat32Array: 'Float32Array', + isFloat64Array: 'Float64Array', + isFormData: 'FormData', + isFunction: 'Function', + isGenerator: 'Generator', + isGeneratorFunction: 'GeneratorFunction', + isInfinite: 'infinite number', + isInRange: 'in range', + isInt16Array: 'Int16Array', + isInt32Array: 'Int32Array', + isInt8Array: 'Int8Array', + isInteger: 'integer', + isIterable: 'Iterable', + isMap: 'Map', + isNan: 'NaN', + isNativePromise: 'native Promise', + isNegativeNumber: 'negative number', + isNodeStream: 'Node.js Stream', + isNonEmptyArray: 'non-empty array', + isNonEmptyMap: 'non-empty map', + isNonEmptyObject: 'non-empty object', + isNonEmptySet: 'non-empty set', + isNonEmptyString: 'non-empty string', + isNonEmptyStringAndNotWhitespace: 'non-empty string and not whitespace', + isNull: 'null', + isNullOrUndefined: 'null or undefined', + isNumber: 'number', + isNumericString: 'string with a number', + isObject: 'Object', + isObservable: 'Observable', + isOddInteger: 'odd integer', + isPlainObject: 'plain object', + isPositiveNumber: 'positive number', + isPrimitive: 'primitive', + isPromise: 'Promise', + isPropertyKey: 'PropertyKey', + isRegExp: 'RegExp', + isSafeInteger: 'integer', + isSet: 'Set', + isSharedArrayBuffer: 'SharedArrayBuffer', + isString: 'string', + isSymbol: 'symbol', + isTruthy: 'truthy', + isTupleLike: 'tuple-like', + isTypedArray: 'TypedArray', + isUint16Array: 'Uint16Array', + isUint32Array: 'Uint32Array', + isUint8Array: 'Uint8Array', + isUint8ClampedArray: 'Uint8ClampedArray', + isUndefined: 'undefined', + isUrlInstance: 'URL', + isUrlSearchParams: 'URLSearchParams', + isUrlString: 'string with a URL', + isValidLength: 'valid length', + isWeakMap: 'WeakMap', + isWeakRef: 'WeakRef', + isWeakSet: 'WeakSet', + isWhitespaceString: 'whitespace string', +} as const; + +function keysOf>(value: T): Array { + return Object.keys(value) as Array; +} + +type IsMethodName = keyof typeof methodTypeMap; +const isMethodNames: IsMethodName[] = keysOf(methodTypeMap); + +function isIsMethodName(value: unknown): value is IsMethodName { + return isMethodNames.includes(value as IsMethodName); +} + export function assertAll(predicate: Predicate, ...values: unknown[]): void | never { if (!isAll(predicate, ...values)) { - throw new TypeError(typeErrorMessageMultipleValue('predicate returns truthy for all values', values)); + const expectedType = isIsMethodName(predicate.name) ? methodTypeMap[predicate.name] : 'predicate returns truthy for all values'; + throw new TypeError(typeErrorMessageMultipleValues(expectedType, values)); } } export function assertAny(predicate: Predicate | Predicate[], ...values: unknown[]): void | never { if (!isAny(predicate, ...values)) { - throw new TypeError(typeErrorMessageMultipleValue('predicate returns truthy for any value', values)); + const predicates = isArray(predicate) ? predicate : [predicate]; + const expectedTypes = predicates.map(predicate => isIsMethodName(predicate.name) ? methodTypeMap[predicate.name] : 'predicate returns truthy for any value'); + throw new TypeError(typeErrorMessageMultipleValues(expectedTypes, values)); } } @@ -1138,6 +1250,12 @@ export function assertDirectInstanceOf(instance: unknown, class_: Class): } } +export function assertDomElement(value: unknown): asserts value is HTMLElement { + if (!isDomElement(value)) { + throw new TypeError(typeErrorMessage('HTMLElement', value)); + } +} + export function assertEmptyArray(value: unknown): asserts value is never[] { if (!isEmptyArray(value)) { throw new TypeError(typeErrorMessage('empty array', value)); @@ -1235,12 +1353,6 @@ export function assertGeneratorFunction(value: unknown): asserts value is Genera } } -export function assertDomElement(value: unknown): asserts value is HTMLElement { - if (!isDomElement(value)) { - throw new TypeError(typeErrorMessage('HTMLElement', value)); - } -} - export function assertInfinite(value: unknown): asserts value is number { if (!isInfinite(value)) { throw new TypeError(typeErrorMessage('infinite number', value)); @@ -1283,12 +1395,6 @@ export function assertIterable(value: unknown): asserts value is It } } -export function assertNativePromise(value: unknown): asserts value is Promise { - if (!isNativePromise(value)) { - throw new TypeError(typeErrorMessage('native Promise', value)); - } -} - export function assertMap(value: unknown): asserts value is Map { if (!isMap(value)) { throw new TypeError(typeErrorMessage('Map', value)); @@ -1301,6 +1407,12 @@ export function assertNan(value: unknown): asserts value is number { } } +export function assertNativePromise(value: unknown): asserts value is Promise { + if (!isNativePromise(value)) { + throw new TypeError(typeErrorMessage('native Promise', value)); + } +} + export function assertNegativeNumber(value: unknown): asserts value is number { if (!isNegativeNumber(value)) { throw new TypeError(typeErrorMessage('negative number', value)); diff --git a/test/test.ts b/test/test.ts index 1db8d75..d28fb1a 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1960,22 +1960,36 @@ test('is.any', t => { t.throws(() => { assert.any(is.string, 1, 2, 3); }, { - // Removes duplicates: - message: /received values of types `number`./, + // Includes expected type and removes duplicates from received types: + message: /Expected values which are `string`. Received values of type `number`./, }); t.throws(() => { assert.any(is.string, 1, [4]); }, { - // Lists all types: - message: /received values of types `number`, `Array`./, + // Includes expected type and lists all received types: + message: /Expected values which are `string`. Received values of types `number` and `Array`./, }); t.throws(() => { assert.any([is.string, is.nullOrUndefined], 1); }, { // Handles array as first argument: - message: /received values of types `number`./, + message: /Expected values which are `string` or `null or undefined`. Received values of type `number`./, + }); + + t.throws(() => { + assert.any([is.string, is.number, is.boolean], null, undefined, Number.NaN); + }, { + // Handles more than 2 expected and received types: + message: /Expected values which are `string`, `number`, or `boolean`. Received values of types `null`, `undefined`, and `NaN`./, + }); + + t.throws(() => { + assert.any(() => false, 1); + }, { + // Default type assertion message + message: /Expected values which are `predicate returns truthy for any value`./, }); }); @@ -2024,15 +2038,22 @@ test('is.all', t => { t.throws(() => { assert.all(is.string, 1, 2, 3); }, { - // Removes duplicates: - message: /received values of types `number`./, + // Includes expected type and removes duplicates from received types: + message: /Expected values which are `string`. Received values of type `number`./, }); t.throws(() => { assert.all(is.string, 1, [4]); }, { - // Lists all types: - message: /received values of types `number`, `Array`./, + // Includes expected type and lists all received types: + message: /Expected values which are `string`. Received values of types `number` and `Array`./, + }); + + t.throws(() => { + assert.all(() => false, 1); + }, { + // Default type assertion message + message: /Expected values which are `predicate returns truthy for all values`./, }); }); diff --git a/tsconfig.json b/tsconfig.json index e1c05ee..5275116 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "outDir": "dist" + "lib": [ + "DOM", + "DOM.Iterable", + "ES2021" + ], + "outDir": "dist", + "target": "ES2021" }, "include": [ "source"