Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add more assertion utils #49

Merged
merged 2 commits into from
Nov 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions src/assert.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
Expand All @@ -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.',
);
});
});
109 changes: 106 additions & 3 deletions src/assert.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<T, S>(
value: unknown,
struct: Struct<T, S>,
errorPrefix = 'Assertion failed',
ErrorWrapper: AssertionErrorConstructor = AssertionError,
): asserts value is T {
try {
assertSuperstruct(value, struct);
} catch (error) {
throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`);
}
}

Expand Down
40 changes: 20 additions & 20 deletions src/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
},
);
Expand All @@ -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.',
);
});

Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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.',
);
});

Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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.',
);
});

Expand All @@ -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.');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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.',
);
});

Expand All @@ -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.');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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.',
);
});

Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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].',
);
});

Expand All @@ -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');
});
});

Expand Down
Loading