diff --git a/.changeset/early-coats-fetch.md b/.changeset/early-coats-fetch.md new file mode 100644 index 0000000..82dc6f0 --- /dev/null +++ b/.changeset/early-coats-fetch.md @@ -0,0 +1,5 @@ +--- +"@esdmr/assert": minor +--- + +New function `wrap` to format messages before creating a `WrappedError`. diff --git a/.changeset/polite-masks-itch.md b/.changeset/polite-masks-itch.md new file mode 100644 index 0000000..726db85 --- /dev/null +++ b/.changeset/polite-masks-itch.md @@ -0,0 +1,6 @@ +--- +"@esdmr/assert": minor +--- + +Allow assertions to be `detail`ed with contextual information. Additionally, this +allows the full message to be formatted. diff --git a/.changeset/three-books-taste.md b/.changeset/three-books-taste.md new file mode 100644 index 0000000..af83022 --- /dev/null +++ b/.changeset/three-books-taste.md @@ -0,0 +1,5 @@ +--- +"@esdmr/assert": patch +--- + +Fix `PrimitiveError` and `WrappedError` having enumerable properties, duplicating output. diff --git a/examples/json.ts b/examples/json.ts index cff3d58..5603f69 100644 --- a/examples/json.ts +++ b/examples/json.ts @@ -4,10 +4,10 @@ export function parseConfig (json: unknown) { try { assert.isObject(json); assert.isNotNull(json); - assert.isString(json.name); - assert.isBoolean(json.private); + assert.isString(json.name, 'property "name"'); + assert.isBoolean(json.private, 'property "private"'); } catch (error) { - throw new assert.WrappedError('Failed to parse config', error); + throw assert.wrap(error, 'Failed to parse config'); } return { diff --git a/src/assert.ts b/src/assert.ts index 7b8d235..46fed32 100644 --- a/src/assert.ts +++ b/src/assert.ts @@ -1,38 +1,37 @@ -import { AssertionError } from './errors.js'; +import { AssertionError, WrappedError } from './errors.js'; import { DEFAULT_MESSAGE } from './messages.js'; +import { format } from './utils.js'; /** - * Formats strings for `assert` function. + * Asserts that a given condition is true. * + * @public + * @param condition - The given condition. * @param message - The message to include in the error. Formatted with `{}`. * @param args - Format arguments. - * @returns The formatted string. */ -function format (message: string, ...args: unknown[]) { - for (const item of args) { - message = message.replace('{}', String(item)); +export function assert ( + condition: boolean, + message = DEFAULT_MESSAGE, + ...args: unknown[] +): asserts condition { + if (!condition) { + throw new AssertionError(format(message, ...args)); } - - return message; } /** - * Asserts that a given condition is true. It formats the message provided with - * the arguments after that which are stringified via `String`. + * Wraps any thrown value. * * @public - * @param condition - The given condition. + * @param thrownValue - The value to wrap. * @param message - The message to include in the error. Formatted with `{}`. * @param args - Format arguments. */ -export function assert ( - condition: boolean, +export function wrap ( + thrownValue: unknown, message = DEFAULT_MESSAGE, ...args: unknown[] -): asserts condition { - if (condition) { - return; - } - - throw new AssertionError(format(message, ...args)); +) { + return new WrappedError(format(message, ...args), thrownValue); } diff --git a/src/errors.ts b/src/errors.ts index 08d9555..acacefa 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -56,9 +56,15 @@ export class AssertionError extends Error { export class PrimitiveError extends Error { name = 'PrimitiveError'; stack = getErrorMessage(this); + readonly value: unknown; - constructor (readonly value: unknown) { + constructor (value: unknown) { super(String(value)); + + // This marks the property as not enumerable and not writable. + Object.defineProperty(this, 'value', { + value, + }); } /** @@ -97,11 +103,17 @@ export class PrimitiveError extends Error { */ export class WrappedError extends Error { name = 'WrappedError'; + readonly thrownValue: unknown; - constructor (message: string, readonly thrownValue: unknown) { + constructor (message: string, thrownValue: unknown) { super(message); Error.captureStackTrace(this, WrappedError); + // This marks the property as not enumerable and not writable. + Object.defineProperty(this, 'thrownValue', { + value: thrownValue, + }); + const error = PrimitiveError.getError(thrownValue); const errorStack = error.stack; diff --git a/src/nullables.ts b/src/nullables.ts index 9b2696f..9cd0e05 100644 --- a/src/nullables.ts +++ b/src/nullables.ts @@ -1,14 +1,21 @@ import * as messages from './messages.js'; +import { addDetail, format } from './utils.js'; /** * Asserts that the given value is not `null`. * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isNotNull (value: T | null): asserts value is T { +export function isNotNull ( + value: T | null, + detail?: string, + ...args: unknown[] +): asserts value is T { if (value === null) { - throw new TypeError(messages.IS_NULL); + throw new TypeError(format(addDetail(messages.IS_NULL, detail), ...args)); } } @@ -17,10 +24,16 @@ export function isNotNull (value: T | null): asserts value is T { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isNonNullable (value: T | null | undefined): asserts value is T { +export function isNonNullable ( + value: T | null | undefined, + detail?: string, + ...args: unknown[] +): asserts value is T { if (value === null || value === undefined) { - throw new TypeError(messages.IS_NULLABLE); + throw new TypeError(format(addDetail(messages.IS_NULLABLE, detail), ...args)); } } @@ -29,9 +42,15 @@ export function isNonNullable (value: T | null | undefined): asserts value is * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isNotUndefined (value: T | undefined): asserts value is T { +export function isNotUndefined ( + value: T | undefined, + detail?: string, + ...args: unknown[] +): asserts value is T { if (value === undefined) { - throw new TypeError(messages.IS_UNDEFINED); + throw new TypeError(format(addDetail(messages.IS_UNDEFINED, detail), ...args)); } } diff --git a/src/numbers.ts b/src/numbers.ts index d802093..0966488 100644 --- a/src/numbers.ts +++ b/src/numbers.ts @@ -1,14 +1,20 @@ import * as messages from './messages.js'; +import { addDetail, format } from './utils.js'; /** * Asserts that the given value is not `NaN`. * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isNotNaN (value: number) { +export function isNotNaN (value: number, detail?: string, ...args: unknown[]) { if (Number.isNaN(value)) { - throw new RangeError(messages.IS_NAN); + throw new RangeError(format( + addDetail(messages.IS_NAN, detail), + ...args, + )); } } @@ -17,10 +23,15 @@ export function isNotNaN (value: number) { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isFinite (value: number) { +export function isFinite (value: number, detail?: string, ...args: unknown[]) { if (!Number.isFinite(value)) { - throw new RangeError(messages.NOT_FINITE); + throw new RangeError(format( + addDetail(messages.NOT_FINITE, detail), + ...args, + )); } } @@ -30,10 +41,15 @@ export function isFinite (value: number) { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isAnyInteger (value: number) { +export function isAnyInteger (value: number, detail?: string, ...args: unknown[]) { if (!Number.isInteger(value)) { - throw new RangeError(messages.NOT_INTEGER); + throw new RangeError(format( + addDetail(messages.NOT_INTEGER, detail), + ...args, + )); } } @@ -42,10 +58,15 @@ export function isAnyInteger (value: number) { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isPositive (value: number) { +export function isPositive (value: number, detail?: string, ...args: unknown[]) { if (value < 0) { - throw new RangeError(messages.NOT_POSITIVE); + throw new RangeError(format( + addDetail(messages.NOT_POSITIVE, detail), + ...args, + )); } } @@ -54,9 +75,14 @@ export function isPositive (value: number) { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isSafeInteger (value: number) { +export function isSafeInteger (value: number, detail?: string, ...args: unknown[]) { if (!Number.isSafeInteger(value)) { - throw new RangeError(messages.NOT_SAFE_INTEGER); + throw new RangeError(format( + addDetail(messages.NOT_SAFE_INTEGER, detail), + ...args, + )); } } diff --git a/src/types.ts b/src/types.ts index 53dc521..c58e44d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import * as messages from './messages.js'; +import { addDetail, format } from './utils.js'; /** @public */ export type FunctionLike = @@ -14,10 +15,19 @@ export type ObjectLike = Record; * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isBigInt (value: unknown): asserts value is bigint { +export function isBigInt ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is bigint { if (typeof value !== 'bigint') { - throw new TypeError(messages.NOT_BIGINT); + throw new TypeError(format( + addDetail(messages.NOT_BIGINT, detail), + ...args, + )); } } @@ -26,10 +36,19 @@ export function isBigInt (value: unknown): asserts value is bigint { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isBoolean (value: unknown): asserts value is boolean { +export function isBoolean ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is boolean { if (typeof value !== 'boolean') { - throw new TypeError(messages.NOT_BOOLEAN); + throw new TypeError(format( + addDetail(messages.NOT_BOOLEAN, detail), + ...args, + )); } } @@ -38,10 +57,19 @@ export function isBoolean (value: unknown): asserts value is boolean { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isFunction (value: unknown): asserts value is FunctionLike { +export function isFunction ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is FunctionLike { if (typeof value !== 'function') { - throw new TypeError(messages.NOT_FUNCTION); + throw new TypeError(format( + addDetail(messages.NOT_FUNCTION, detail), + ...args, + )); } } @@ -50,10 +78,19 @@ export function isFunction (value: unknown): asserts value is FunctionLike { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isNumber (value: unknown): asserts value is number { +export function isNumber ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is number { if (typeof value !== 'number') { - throw new TypeError(messages.NOT_NUMBER); + throw new TypeError(format( + addDetail(messages.NOT_NUMBER, detail), + ...args, + )); } } @@ -63,10 +100,19 @@ export function isNumber (value: unknown): asserts value is number { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isObject (value: unknown): asserts value is ObjectLike | null { +export function isObject ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is ObjectLike | null { if (typeof value !== 'object') { - throw new TypeError(messages.NOT_OBJECT); + throw new TypeError(format( + addDetail(messages.NOT_OBJECT, detail), + ...args, + )); } } @@ -75,10 +121,19 @@ export function isObject (value: unknown): asserts value is ObjectLike | null { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isString (value: unknown): asserts value is string { +export function isString ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is string { if (typeof value !== 'string') { - throw new TypeError(messages.NOT_STRING); + throw new TypeError(format( + addDetail(messages.NOT_STRING, detail), + ...args, + )); } } @@ -87,9 +142,18 @@ export function isString (value: unknown): asserts value is string { * * @public * @param value - Value to assert. + * @param detail - Extra description. + * @param args - Format arguments. */ -export function isSymbol (value: unknown): asserts value is symbol { +export function isSymbol ( + value: unknown, + detail?: string, + ...args: unknown[] +): asserts value is symbol { if (typeof value !== 'symbol') { - throw new TypeError(messages.NOT_SYMBOL); + throw new TypeError(format( + addDetail(messages.NOT_SYMBOL, detail), + ...args, + )); } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ac97f6c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,27 @@ +/** + * Formats strings for `assert` function. + * + * @internal + * @param message - The message to include in the error. Formatted with `{}`. + * @param args - Format arguments. + * @returns The formatted string. + */ +export function format (message: string, ...args: unknown[]) { + for (const item of args) { + message = message.replace('{}', String(item)); + } + + return message; +} + +/** + * Provides more detail for a message. + * + * @internal + * @param message - The message to include in the error. + * @param detail - Extra description. + * @returns Concatentated message. + */ +export function addDetail (message: string, detail?: string) { + return typeof detail === 'string' ? `${message} (${detail})` : message; +} diff --git a/test/assert.ts b/test/assert.ts index 0f983e0..990bca6 100644 --- a/test/assert.ts +++ b/test/assert.ts @@ -1,30 +1,35 @@ import { test } from 'tap'; -import { assert } from '#src/assert.js'; -import { AssertionError } from '#src/errors.js'; +import { testFormat } from './test-util/format.js'; +import { assert, wrap } from '#src/assert.js'; +import { AssertionError, WrappedError } from '#src/errors.js'; import { DEFAULT_MESSAGE } from '#src/messages.js'; await test('assert', async (t) => { - t.doesNotThrow(() => { - assert(true, 'This should not throw'); - }, 'expected to not throw if condition is true'); + t.doesNotThrow( + () => { + assert(true, 'This should not throw'); + }, + 'expected to not throw if condition is true', + ); - t.throws(() => { - assert(false, 'This should throw'); - }, 'expected to throw if condition is true'); + t.throws( + () => { + assert(false); + }, + new AssertionError(DEFAULT_MESSAGE), + 'expected to throw the default message if condition is false', + ); - t.throws(() => { - assert(false); - }, new AssertionError(DEFAULT_MESSAGE)); - - t.throws(() => { - assert(false, 'Custom message (arity 0)'); - }, new AssertionError('Custom message (arity 0)')); + testFormat(t, (...args) => { + assert(false, ...args); + }, (message: string) => new AssertionError(message)); +}); - t.throws(() => { - assert(false, '1{}3 (arity 1)', '(2)'); - }, new AssertionError('1(2)3 (arity 1)')); +await test('wrap', async (t) => { + t.strictSame(wrap('value'), wrap('value', DEFAULT_MESSAGE), + 'expected to use the default message if not given one'); - t.throws(() => { - assert(false, '1{}3{}5 (arity 2)', '(2)', '(4)'); - }, new AssertionError('1(2)3(4)5 (arity 2)')); + testFormat(t, (...args) => { + throw wrap('value', ...args); + }, (message: string) => new WrappedError(message, 'value')); }); diff --git a/test/nullables.ts b/test/nullables.ts index ab513ac..6ea035e 100644 --- a/test/nullables.ts +++ b/test/nullables.ts @@ -1,4 +1,5 @@ import { test } from 'tap'; +import { testDetail } from './test-util/format.js'; import * as nullables from '#src/nullables.js'; import * as messages from '#src/messages.js'; @@ -17,6 +18,10 @@ await test('isNull', async (t) => { }, 'expected to not throw an error if the value is not null', ); + + testDetail(t, (...args) => { + nullables.isNotNull(null, ...args); + }, TypeError, messages.IS_NULL); }); await test('isNonNullable', async (t) => { @@ -42,6 +47,10 @@ await test('isNonNullable', async (t) => { }, 'expected to not throw an error if the value is not nullable', ); + + testDetail(t, (...args) => { + nullables.isNonNullable(undefined, ...args); + }, TypeError, messages.IS_NULLABLE); }); await test('isNotUndefined', async (t) => { @@ -59,4 +68,8 @@ await test('isNotUndefined', async (t) => { }, 'expected to not throw an error if the value is not undefined', ); + + testDetail(t, (...args) => { + nullables.isNotUndefined(undefined, ...args); + }, TypeError, messages.IS_UNDEFINED); }); diff --git a/test/numbers.ts b/test/numbers.ts index 5646c61..9cf6250 100644 --- a/test/numbers.ts +++ b/test/numbers.ts @@ -1,4 +1,5 @@ import { test } from 'tap'; +import { testDetail } from './test-util/format.js'; import * as numbers from '#src/numbers.js'; import * as messages from '#src/messages.js'; @@ -56,6 +57,10 @@ await test('isNotNaN', async (t) => { testFloat(t, numbers.isNotNaN, messages.IS_NAN, { NaN: false, }); + + testDetail(t, (...args) => { + numbers.isNotNaN(Number.NaN, ...args); + }, RangeError, messages.IS_NAN); }); await test('isFinite', async (t) => { @@ -64,6 +69,10 @@ await test('isFinite', async (t) => { NEGATIVE_INFINITY: false, NaN: false, }); + + testDetail(t, (...args) => { + numbers.isFinite(Number.POSITIVE_INFINITY, ...args); + }, RangeError, messages.NOT_FINITE); }); await test('isAnyInteger', async (t) => { @@ -80,6 +89,10 @@ await test('isAnyInteger', async (t) => { NEGATIVE_MIN_VALUE: false, NaN: false, }); + + testDetail(t, (...args) => { + numbers.isAnyInteger(0.5, ...args); + }, RangeError, messages.NOT_INTEGER); }); await test('isPositive', async (t) => { @@ -93,6 +106,10 @@ await test('isPositive', async (t) => { NEGATIVE_MAX_VALUE: false, NEGATIVE_MIN_VALUE: false, }); + + testDetail(t, (...args) => { + numbers.isPositive(-1, ...args); + }, RangeError, messages.NOT_POSITIVE); }); await test('isSafeInteger', async (t) => { @@ -111,4 +128,8 @@ await test('isSafeInteger', async (t) => { NEGATIVE_MIN_VALUE: false, NaN: false, }); + + testDetail(t, (...args) => { + numbers.isSafeInteger(0.5, ...args); + }, RangeError, messages.NOT_SAFE_INTEGER); }); diff --git a/test/test-util/format.ts b/test/test-util/format.ts new file mode 100644 index 0000000..e06e3b6 --- /dev/null +++ b/test/test-util/format.ts @@ -0,0 +1,40 @@ +import { addDetail } from '#src/utils.js'; + +export function testFormat ( + t: Tap.Test, + func: (message: string, ...args: unknown[]) => void, + newError: (message: string) => Error, +) { + t.throws( + () => { + func('Custom message (arity 0)'); + }, + newError('Custom message (arity 0)'), + 'expected to format correctly with arity 0', + ); + + t.throws( + () => { + func('1{}3 (arity 1)', '(2)'); + }, + newError('1(2)3 (arity 1)'), + 'expected to format correctly with arity 1', + ); + + t.throws( + () => { + func('1{}3{}5 (arity 2)', '(2)', '(4)'); + }, + newError('1(2)3(4)5 (arity 2)'), + 'expected to format correctly with arity 2', + ); +} + +export function testDetail ( + t: Tap.Test, + func: (detail: string, ...args: unknown[]) => void, + newError: (message: string) => Error, + message: string, +) { + testFormat(t, func, (detail: string) => newError(addDetail(message, detail))); +} diff --git a/test/types.ts b/test/types.ts index 5bcb956..d87c814 100644 --- a/test/types.ts +++ b/test/types.ts @@ -1,4 +1,5 @@ import { test } from 'tap'; +import { testDetail } from './test-util/format.js'; import * as types from '#src/types.js'; import * as messages from '#src/messages.js'; @@ -40,28 +41,56 @@ function testType ( await test('isBigInt', async (t) => { testType(t, types.isBigInt, messages.NOT_BIGINT, 'bigint'); + + testDetail(t, (...args) => { + types.isBigInt(undefined, ...args); + }, TypeError, messages.NOT_BIGINT); }); await test('isBoolean', async (t) => { testType(t, types.isBoolean, messages.NOT_BOOLEAN, 'boolean'); + + testDetail(t, (...args) => { + types.isBoolean(undefined, ...args); + }, TypeError, messages.NOT_BOOLEAN); }); await test('isFunction', async (t) => { testType(t, types.isFunction, messages.NOT_FUNCTION, 'function'); + + testDetail(t, (...args) => { + types.isFunction(undefined, ...args); + }, TypeError, messages.NOT_FUNCTION); }); await test('isNumber', async (t) => { testType(t, types.isNumber, messages.NOT_NUMBER, 'number'); + + testDetail(t, (...args) => { + types.isNumber(undefined, ...args); + }, TypeError, messages.NOT_NUMBER); }); await test('isObject', async (t) => { testType(t, types.isObject, messages.NOT_OBJECT, 'object'); + + testDetail(t, (...args) => { + types.isObject(undefined, ...args); + }, TypeError, messages.NOT_OBJECT); }); await test('isString', async (t) => { testType(t, types.isString, messages.NOT_STRING, 'string'); + + testDetail(t, (...args) => { + types.isString(undefined, ...args); + }, TypeError, messages.NOT_STRING); }); await test('isSymbol', async (t) => { testType(t, types.isSymbol, messages.NOT_SYMBOL, 'symbol'); + + testDetail(t, (...args) => { + types.isSymbol(undefined, ...args); + }, TypeError, messages.NOT_SYMBOL); });