diff --git a/src/assert.test.ts b/src/assert.test.ts index e35b2aa54..edb3f26b5 100644 --- a/src/assert.test.ts +++ b/src/assert.test.ts @@ -1,4 +1,11 @@ -import { assert, assertExhaustive, AssertionError } from './assert'; +import { string } from 'superstruct'; +import * as superstructModule from 'superstruct'; +import { + assert, + assertExhaustive, + AssertionError, + assertStruct, +} from './assert'; describe('assert', () => { it('succeeds', () => { @@ -22,7 +29,7 @@ describe('assert', () => { expect(() => assert(false)).toThrow('Assertion failed.'); }); - it('throw custom error', () => { + it('throws a custom error', () => { class MyError extends Error {} expect(() => assert(false, new MyError('Thrown'))).toThrow(MyError); }); @@ -35,3 +42,67 @@ describe('assertExhaustive', () => { ); }); }); + +describe('assertStruct', () => { + it('does not throw for a valid value', () => { + expect(() => assertStruct('foo', string())).not.toThrow(); + }); + + it('throws meaningful error messages for an invalid value', () => { + expect(() => assertStruct(undefined, string())).toThrow( + 'Assertion failed: Expected a string, but received: undefined.', + ); + + expect(() => assertStruct(1, string())).toThrow( + 'Assertion failed: Expected a string, but received: 1.', + ); + }); + + it('throws with a custom error prefix', () => { + expect(() => assertStruct(null, string(), 'Invalid string')).toThrow( + 'Invalid string: Expected a string, but received: null.', + ); + }); + + it('throws with a custom error class', () => { + class CustomError extends Error { + constructor({ message }: { message: string }) { + super(message); + this.name = 'CustomError'; + } + } + + expect(() => + assertStruct({ data: 'foo' }, string(), 'Invalid string', CustomError), + ).toThrow( + new CustomError({ + message: + 'Invalid string: Expected a string, but received: [object Object].', + }), + ); + }); + + it('throws with a custom error function', () => { + const CustomError = ({ message }: { message: string }) => + new Error(message); + + expect(() => + assertStruct({ data: 'foo' }, string(), 'Invalid string', CustomError), + ).toThrow( + CustomError({ + message: + 'Invalid string: Expected a string, but received: [object Object].', + }), + ); + }); + + it('includes the value thrown in the message if it is not an error', () => { + jest.spyOn(superstructModule, 'assert').mockImplementation(() => { + throw 'foo.'; + }); + + expect(() => assertStruct(true, string())).toThrow( + 'Assertion failed: foo.', + ); + }); +}); diff --git a/src/assert.ts b/src/assert.ts index e8b9c48d6..949ef817d 100644 --- a/src/assert.ts +++ b/src/assert.ts @@ -1,3 +1,74 @@ +import { assert as assertSuperstruct, Struct } from 'superstruct'; + +export type AssertionErrorConstructor = + | (new (args: { message: string }) => Error) + | ((args: { message: string }) => Error); + +/** + * Type guard for determining whether the given value is an error object with a + * `message` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +function isErrorWithMessage(error: unknown): error is { message: string } { + return typeof error === 'object' && error !== null && 'message' in error; +} + +/** + * Check if a value is a constructor, i.e., a function that can be called with + * the `new` keyword. + * + * @param fn - The value to check. + * @returns `true` if the value is a constructor, or `false` otherwise. + */ +function isConstructable( + fn: AssertionErrorConstructor, +): fn is new (args: { message: string }) => Error { + /* istanbul ignore next */ + return Boolean(typeof fn?.prototype?.constructor?.name === 'string'); +} + +/** + * Get the error message from an unknown error object. If the error object has + * a `message` property, that property is returned. Otherwise, the stringified + * error object is returned. + * + * @param error - The error object to get the message from. + * @returns The error message. + */ +function getErrorMessage(error: unknown): string { + const message = isErrorWithMessage(error) ? error.message : String(error); + + // If the error ends with a period, remove it, as we'll add our own period. + if (message.endsWith('.')) { + return message.slice(0, -1); + } + + return message; +} + +/** + * Initialise an {@link AssertionErrorConstructor} error. + * + * @param ErrorWrapper - The error class to use. + * @param message - The error message. + * @returns The error object. + */ +function getError(ErrorWrapper: AssertionErrorConstructor, message: string) { + if (isConstructable(ErrorWrapper)) { + return new ErrorWrapper({ + message, + }); + } + return ErrorWrapper({ + message, + }); +} + +/** + * The default error class that is thrown if an assertion fails. + */ export class AssertionError extends Error { readonly code = 'ERR_ASSERTION'; @@ -10,17 +81,49 @@ export class AssertionError extends Error { * Same as Node.js assert. * If the value is falsy, throws an error, does nothing otherwise. * - * @throws {@link AssertionError}. If value is falsy. + * @throws {@link AssertionError} If value is falsy. * @param value - The test that should be truthy to pass. * @param message - Message to be passed to {@link AssertionError} or an * {@link Error} instance to throw. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. If a custom error class is provided for + * the `message` argument, this argument is ignored. */ -export function assert(value: any, message?: string | Error): asserts value { +export function assert( + value: any, + message: string | Error = 'Assertion failed.', + ErrorWrapper: AssertionErrorConstructor = AssertionError, +): asserts value { if (!value) { if (message instanceof Error) { throw message; } - throw new AssertionError({ message: message ?? 'Assertion failed.' }); + + throw getError(ErrorWrapper, message); + } +} + +/** + * Assert a value against a Superstruct struct. + * + * @param value - The value to validate. + * @param struct - The struct to validate against. + * @param errorPrefix - A prefix to add to the error message. Defaults to + * "Assertion failed". + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the value is not valid. + */ +export function assertStruct( + value: unknown, + struct: Struct, + errorPrefix = 'Assertion failed', + ErrorWrapper: AssertionErrorConstructor = AssertionError, +): asserts value is T { + try { + assertSuperstruct(value, struct); + } catch (error) { + throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`); } } diff --git a/src/json.test.ts b/src/json.test.ts index 0337b541e..dc947c096 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -78,7 +78,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC notifications', (notification) => { expect(() => assertIsJsonRpcNotification(notification)).toThrow( - 'Not a JSON-RPC notification', + 'Invalid JSON-RPC notification', ); }, ); @@ -87,7 +87,7 @@ describe('json', () => { expect(() => assertIsJsonRpcNotification(JSON_RPC_NOTIFICATION_FIXTURES.invalid[0]), ).toThrow( - 'Not a JSON-RPC notification: At path: jsonrpc -- Expected the literal `"2.0"`, but received: undefined.', + 'Invalid JSON-RPC notification: At path: jsonrpc -- Expected the literal `"2.0"`, but received: undefined.', ); }); @@ -98,7 +98,7 @@ describe('json', () => { expect(() => assertIsJsonRpcNotification(JSON_RPC_NOTIFICATION_FIXTURES.invalid[0]), - ).toThrow('Not a JSON-RPC notification: oops'); + ).toThrow('Invalid JSON-RPC notification: oops'); }); }); @@ -130,7 +130,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC requests', (request) => { expect(() => assertIsJsonRpcRequest(request)).toThrow( - 'Not a JSON-RPC request', + 'Invalid JSON-RPC request', ); }, ); @@ -139,7 +139,7 @@ describe('json', () => { expect(() => assertIsJsonRpcRequest(JSON_RPC_REQUEST_FIXTURES.invalid[0]), ).toThrow( - 'Not a JSON-RPC request: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', + 'Invalid JSON-RPC request: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', ); }); @@ -150,7 +150,7 @@ describe('json', () => { expect(() => assertIsJsonRpcRequest(JSON_RPC_REQUEST_FIXTURES.invalid[0]), - ).toThrow('Not a JSON-RPC request: oops'); + ).toThrow('Invalid JSON-RPC request: oops'); }); }); @@ -182,7 +182,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC success', (success) => { expect(() => assertIsJsonRpcSuccess(success)).toThrow( - 'Not a successful JSON-RPC response', + 'Invalid JSON-RPC success response', ); }, ); @@ -191,7 +191,7 @@ describe('json', () => { expect(() => assertIsJsonRpcSuccess(JSON_RPC_SUCCESS_FIXTURES.invalid[0]), ).toThrow( - 'Not a successful JSON-RPC response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', + 'Invalid JSON-RPC success response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', ); }); @@ -202,7 +202,7 @@ describe('json', () => { expect(() => assertIsJsonRpcSuccess(JSON_RPC_SUCCESS_FIXTURES.invalid[0]), - ).toThrow('Not a successful JSON-RPC response: oops'); + ).toThrow('Invalid JSON-RPC success response: oops.'); }); }); @@ -234,7 +234,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC failure', (failure) => { expect(() => assertIsJsonRpcFailure(failure)).toThrow( - 'Not a failed JSON-RPC response', + 'Invalid JSON-RPC failure response', ); }, ); @@ -243,7 +243,7 @@ describe('json', () => { expect(() => assertIsJsonRpcFailure(JSON_RPC_FAILURE_FIXTURES.invalid[0]), ).toThrow( - 'Not a failed JSON-RPC response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', + 'Invalid JSON-RPC failure response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.', ); }); @@ -254,7 +254,7 @@ describe('json', () => { expect(() => assertIsJsonRpcFailure(JSON_RPC_FAILURE_FIXTURES.invalid[0]), - ).toThrow('Not a failed JSON-RPC response: oops'); + ).toThrow('Invalid JSON-RPC failure response: oops.'); }); }); @@ -286,7 +286,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC error', (error) => { expect(() => assertIsJsonRpcError(error)).toThrow( - 'Not a JSON-RPC error', + 'Invalid JSON-RPC error', ); }, ); @@ -295,7 +295,7 @@ describe('json', () => { expect(() => assertIsJsonRpcError(JSON_RPC_ERROR_FIXTURES.invalid[0]), ).toThrow( - 'Not a JSON-RPC error: At path: code -- Expected an integer, but received: undefined.', + 'Invalid JSON-RPC error: At path: code -- Expected an integer, but received: undefined.', ); }); @@ -306,7 +306,7 @@ describe('json', () => { expect(() => assertIsJsonRpcError(JSON_RPC_ERROR_FIXTURES.invalid[0]), - ).toThrow('Not a JSON-RPC error: oops'); + ).toThrow('Invalid JSON-RPC error: oops'); }); }); @@ -338,7 +338,7 @@ describe('json', () => { 'throws for an invalid pending JSON-RPC response', (response) => { expect(() => assertIsPendingJsonRpcResponse(response)).toThrow( - 'Not a pending JSON-RPC response', + 'Invalid pending JSON-RPC response', ); }, ); @@ -350,7 +350,7 @@ describe('json', () => { expect(() => assertIsPendingJsonRpcResponse(JSON_RPC_FAILURE_FIXTURES.invalid[0]), - ).toThrow('Not a pending JSON-RPC response: oops'); + ).toThrow('Invalid pending JSON-RPC response: oops'); }); }); @@ -382,7 +382,7 @@ describe('json', () => { 'throws an error for invalid JSON-RPC response', (response) => { expect(() => assertIsJsonRpcResponse(response)).toThrow( - 'Not a JSON-RPC response', + 'Invalid JSON-RPC response', ); }, ); @@ -391,7 +391,7 @@ describe('json', () => { expect(() => assertIsJsonRpcResponse(JSON_RPC_RESPONSE_FIXTURES.invalid[0]), ).toThrow( - 'Not a JSON-RPC response: Expected the value to satisfy a union of `object | object`, but received: [object Object].', + 'Invalid JSON-RPC response: Expected the value to satisfy a union of `object | object`, but received: [object Object].', ); }); @@ -402,7 +402,7 @@ describe('json', () => { expect(() => assertIsJsonRpcResponse(JSON_RPC_RESPONSE_FIXTURES.invalid[0]), - ).toThrow('Not a JSON-RPC response: oops'); + ).toThrow('Invalid JSON-RPC response: oops'); }); }); diff --git a/src/json.ts b/src/json.ts index 431a9b3ff..7847b9322 100644 --- a/src/json.ts +++ b/src/json.ts @@ -1,6 +1,5 @@ import { array, - assert, define, Infer, integer, @@ -22,17 +21,7 @@ import { isPlainObject, JsonSize, } from './misc'; - -/** - * Type guard for determining whether the given value is an error object with a - * `message` property, such as an instance of Error. - * - * @param error - The object to check. - * @returns True or false, depending on the result. - */ -function isErrorWithMessage(error: unknown): error is { message: string } { - return typeof error === 'object' && error !== null && 'message' in error; -} +import { AssertionErrorConstructor, assertStruct } from './assert'; export const JsonStruct = define('Json', (value) => { const [isValid] = validateJsonAndGetSize(value, true); @@ -51,10 +40,11 @@ export type Json = | { [prop: string]: Json }; /** - * Type guard for {@link Json}. + * Check if the given value is a valid {@link Json} value, i.e., a value that is + * serializable to JSON. * * @param value - The value to check. - * @returns Whether the value is valid JSON. + * @returns Whether the value is a valid {@link Json} value. */ export function isValidJson(value: unknown): value is Json { return is(value, JsonStruct); @@ -151,63 +141,68 @@ export type JsonRpcNotification = InferWithParams< >; /** - * Type guard to narrow a {@link JsonRpcRequest} or - * {@link JsonRpcNotification} object to a {@link JsonRpcNotification}. + * Check if the given value is a valid {@link JsonRpcNotification} object. * - * @param requestOrNotification - The JSON-RPC request or notification to check. - * @returns Whether the specified JSON-RPC message is a notification. + * @param value - The value to check. + * @returns Whether the given value is a valid {@link JsonRpcNotification} + * object. */ export function isJsonRpcNotification( - requestOrNotification: unknown, -): requestOrNotification is JsonRpcNotification { - return is(requestOrNotification, JsonRpcNotificationStruct); + value: unknown, +): value is JsonRpcNotification { + return is(value, JsonRpcNotificationStruct); } /** - * Assertion type guard to narrow a {@link JsonRpcRequest} or - * {@link JsonRpcNotification} object to a {@link JsonRpcNotification}. + * Assert that the given value is a valid {@link JsonRpcNotification} object. * - * @param requestOrNotification - The JSON-RPC request or notification to check. + * @param value - The value to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcNotification} object. */ export function assertIsJsonRpcNotification( - requestOrNotification: unknown, -): asserts requestOrNotification is JsonRpcNotification { - try { - assert(requestOrNotification, JsonRpcNotificationStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a JSON-RPC notification: ${message}.`); - } + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is JsonRpcNotification { + assertStruct( + value, + JsonRpcNotificationStruct, + 'Invalid JSON-RPC notification', + ErrorWrapper, + ); } /** - * Type guard to narrow a {@link JsonRpcRequest} or @link JsonRpcNotification} - * object to a {@link JsonRpcRequest}. + * Check if the given value is a valid {@link JsonRpcRequest} object. * - * @param requestOrNotification - The JSON-RPC request or notification to check. - * @returns Whether the specified JSON-RPC message is a request. + * @param value - The value to check. + * @returns Whether the given value is a valid {@link JsonRpcRequest} object. */ export function isJsonRpcRequest( - requestOrNotification: unknown, -): requestOrNotification is JsonRpcRequest { - return is(requestOrNotification, JsonRpcRequestStruct); + value: unknown, +): value is JsonRpcRequest { + return is(value, JsonRpcRequestStruct); } /** - * Assertion type guard to narrow a {@link JsonRpcRequest} or - * {@link JsonRpcNotification} object to a {@link JsonRpcRequest}. + * Assert that the given value is a valid {@link JsonRpcRequest} object. * - * @param requestOrNotification - The JSON-RPC request or notification to check. + * @param value - The JSON-RPC request or notification to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcRequest} object. */ export function assertIsJsonRpcRequest( - requestOrNotification: unknown, -): asserts requestOrNotification is JsonRpcRequest { - try { - assert(requestOrNotification, JsonRpcRequestStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a JSON-RPC request: ${message}.`); - } + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is JsonRpcRequest { + assertStruct( + value, + JsonRpcRequestStruct, + 'Invalid JSON-RPC request', + ErrorWrapper, + ); } export const PendingJsonRpcResponseStruct = object({ @@ -283,21 +278,24 @@ export function isPendingJsonRpcResponse( } /** - * Assert that the specified JSON-RPC response is a - * {@link PendingJsonRpcResponse}. + * Assert that the given value is a valid {@link PendingJsonRpcResponse} object. * * @param response - The JSON-RPC response to check. - * @throws If the specified JSON-RPC response is not pending. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link PendingJsonRpcResponse} + * object. */ export function assertIsPendingJsonRpcResponse( response: unknown, + ErrorWrapper?: AssertionErrorConstructor, ): asserts response is PendingJsonRpcResponse { - try { - assert(response, PendingJsonRpcResponseStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a pending JSON-RPC response: ${message}.`); - } + assertStruct( + response, + PendingJsonRpcResponseStruct, + 'Invalid pending JSON-RPC response', + ErrorWrapper, + ); } /** @@ -313,106 +311,115 @@ export function isJsonRpcResponse( } /** - * Type assertion to check if a value is a {@link JsonRpcResponse}. + * Assert that the given value is a valid {@link JsonRpcResponse} object. * - * @param response - The response to check. + * @param value - The value to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcResponse} object. */ export function assertIsJsonRpcResponse( - response: unknown, -): asserts response is JsonRpcResponse { - try { - assert(response, JsonRpcResponseStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a JSON-RPC response: ${message}.`); - } + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is JsonRpcResponse { + assertStruct( + value, + JsonRpcResponseStruct, + 'Invalid JSON-RPC response', + ErrorWrapper, + ); } /** - * Type guard to narrow a {@link JsonRpcResponse} object to a success - * (or failure). + * Check if the given value is a valid {@link JsonRpcSuccess} object. * - * @param response - The response object to check. - * @returns Whether the response object is a success. + * @param value - The value to check. + * @returns Whether the given value is a valid {@link JsonRpcSuccess} object. */ export function isJsonRpcSuccess( - response: unknown, -): response is JsonRpcSuccess { - return is(response, JsonRpcSuccessStruct); + value: unknown, +): value is JsonRpcSuccess { + return is(value, JsonRpcSuccessStruct); } /** - * Type assertion to narrow a {@link JsonRpcResponse} object to a success - * (or failure). + * Assert that the given value is a valid {@link JsonRpcSuccess} object. * - * @param response - The response object to check. + * @param value - The value to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcSuccess} object. */ export function assertIsJsonRpcSuccess( - response: unknown, -): asserts response is JsonRpcSuccess { - try { - assert(response, JsonRpcSuccessStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a successful JSON-RPC response: ${message}.`); - } + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is JsonRpcSuccess { + assertStruct( + value, + JsonRpcSuccessStruct, + 'Invalid JSON-RPC success response', + ErrorWrapper, + ); } /** - * Type guard to narrow a {@link JsonRpcResponse} object to a failure - * (or success). + * Check if the given value is a valid {@link JsonRpcFailure} object. * - * @param response - The response object to check. - * @returns Whether the response object is a failure, i.e. has an `error` - * property. + * @param value - The value to check. + * @returns Whether the given value is a valid {@link JsonRpcFailure} object. */ -export function isJsonRpcFailure( - response: unknown, -): response is JsonRpcFailure { - return is(response, JsonRpcFailureStruct); +export function isJsonRpcFailure(value: unknown): value is JsonRpcFailure { + return is(value, JsonRpcFailureStruct); } /** - * Type assertion to narrow a {@link JsonRpcResponse} object to a failure - * (or success). + * Assert that the given value is a valid {@link JsonRpcFailure} object. * - * @param response - The response object to check. + * @param value - The value to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcFailure} object. */ export function assertIsJsonRpcFailure( - response: unknown, -): asserts response is JsonRpcFailure { - try { - assert(response, JsonRpcFailureStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a failed JSON-RPC response: ${message}.`); - } + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is JsonRpcFailure { + assertStruct( + value, + JsonRpcFailureStruct, + 'Invalid JSON-RPC failure response', + ErrorWrapper, + ); } /** - * Type guard to validate whether an object is a valid JSON-RPC error. + * Check if the given value is a valid {@link JsonRpcError} object. * - * @param value - The value object to check. - * @returns Whether the response object is a valid JSON-RPC error. + * @param value - The value to check. + * @returns Whether the given value is a valid {@link JsonRpcError} object. */ export function isJsonRpcError(value: unknown): value is JsonRpcError { return is(value, JsonRpcErrorStruct); } /** - * Type assertion to validate whether an object is a valid JSON-RPC error. + * Assert that the given value is a valid {@link JsonRpcError} object. * - * @param value - The value object to check. + * @param value - The value to check. + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. + * @throws If the given value is not a valid {@link JsonRpcError} object. */ export function assertIsJsonRpcError( value: unknown, + ErrorWrapper?: AssertionErrorConstructor, ): asserts value is JsonRpcError { - try { - assert(value, JsonRpcErrorStruct); - } catch (error) { - const message = isErrorWithMessage(error) ? error.message : error; - throw new Error(`Not a JSON-RPC error: ${message}.`); - } + assertStruct( + value, + JsonRpcErrorStruct, + 'Invalid JSON-RPC error', + ErrorWrapper, + ); } type JsonRpcValidatorOptions = {