assert.ts
TypeScript source for Type safety at the boundaries, 257 lines. Raw file
/**
* A very small assertion library, for checking data as it comes into a TypeScript application.
*
* Every check takes a value of unknown type and either returns it, typed, or throws an
* AssertException. Checks that need to know what's inside a value, like Array, are curried:
* they take the check for the contents, and return a check for the whole thing.
*/
/**
* The exception thrown by every failed check.
*/
export class AssertException extends Error {
constructor(message: string) {
super(message);
this.name = 'AssertException';
// Keeps instanceof working when compiled down to ES5
Object.setPrototypeOf(this, AssertException.prototype);
}
}
/**
* A check: takes a value of unknown type, and returns it as a T or throws.
*/
export type Check<T> = (value: unknown) => T;
/**
* An object straight from JSON, whose fields haven't been checked yet.
*/
export type JsonObject = { [key: string]: unknown };
/**
* Descriptions of what's being done, innermost last, added to every failure message.
*/
const doing: string[] = [];
/**
* Runs a callback with a description of what it's doing, so any failure inside says what was going on.
*
* @param description What the callback is doing, e.g. 'decoding project 42'
* @param callback The code to run
* @returns Whatever the callback returns
*/
function AssertEnter<T>(description: string, callback: () => T): T {
doing.push(description);
try {
return callback();
}
finally {
doing.pop();
}
}
/**
* Throws an AssertException with a message, followed by the descriptions recorded with Enter.
*
* @param message What went wrong
*/
function AssertFail(message: string): never {
const lines = doing.map((description) => '\n while: ' + description);
const context = lines.join('');
throw new AssertException(message + context);
}
/**
* Fails unless a condition is true.
*
* @param condition The condition
* @param optionalMessage The message to fail with, instead of 'Check failed'
*/
function AssertCheck(condition: boolean, optionalMessage?: string): void {
if (!condition) {
const message = optionalMessage === undefined ? 'Check failed' : optionalMessage;
AssertFail(message);
}
}
/**
* Describes a value for a failure message: its type, and the value itself if it's a simple one.
*/
function Describe(value: unknown): string {
if (value === null) {
return 'null';
}
if (Array.isArray(value)) {
return 'array';
}
if (typeof value === 'string') {
const json = JSON.stringify(value);
return 'string: ' + json;
}
if (typeof value === 'number' || typeof value === 'boolean') {
const text = String(value);
return typeof value + ': ' + text;
}
return typeof value;
}
/**
* Fails unless the value is a string.
*
* @param value The value to check
* @returns The value
*/
function AssertString(value: unknown): string {
if (typeof value !== 'string') {
AssertFail('Expected string; got ' + Describe(value));
}
return value;
}
/**
* Fails unless the value is a number, and not NaN, optionally within limits.
*
* @param value The value to check
* @param optionalMin The smallest value allowed
* @param optionalMax The largest value allowed
* @returns The value
*/
function AssertNumber(value: unknown, optionalMin?: number, optionalMax?: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
AssertFail('Expected number; got ' + Describe(value));
}
if (optionalMin !== undefined && value < optionalMin) {
AssertFail('Expected number >= ' + optionalMin + '; got ' + value);
}
if (optionalMax !== undefined && value > optionalMax) {
AssertFail('Expected number <= ' + optionalMax + '; got ' + value);
}
return value;
}
/**
* Fails unless the value is a whole number, optionally within limits.
*
* @param value The value to check
* @param optionalMin The smallest value allowed
* @param optionalMax The largest value allowed
* @returns The value
*/
function AssertInteger(value: unknown, optionalMin?: number, optionalMax?: number): number {
const number = AssertNumber(value, optionalMin, optionalMax);
if (!Number.isInteger(number)) {
AssertFail('Expected integer; got ' + number);
}
return number;
}
/**
* Fails unless the value is true or false.
*
* @param value The value to check
* @returns The value
*/
function AssertBoolean(value: unknown): boolean {
if (typeof value !== 'boolean') {
AssertFail('Expected boolean; got ' + Describe(value));
}
return value;
}
/**
* Fails unless the value is an object: not null, and not an array.
*
* @param value The value to check
* @returns The value, as an object whose fields are still to be checked
*/
function AssertObject(value: unknown): JsonObject {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
AssertFail('Expected object; got ' + Describe(value));
}
return value as JsonObject;
}
/**
* Makes a check for an array, checking every element.
*
* @param of The check for each element
* @returns A check that takes the value and returns a new array of what the element check returns
*/
function AssertArray<T>(of: Check<T>): Check<T[]> {
return (value: unknown) => {
if (!Array.isArray(value)) {
AssertFail('Expected array; got ' + Describe(value));
}
const checkElement = (element: unknown, index: number) => {
return AssertEnter('checking element ' + index, () => of(element));
};
return value.map(checkElement);
};
}
/**
* Makes a check for an object used as a dictionary, checking every value in it.
*
* @param of The check for each value
* @returns A check that takes the value and returns a new dictionary of what the value check returns
*/
function AssertDictionary<T>(of: Check<T>): Check<{ [key: string]: T }> {
return (value: unknown) => {
const object = AssertObject(value);
const result: { [key: string]: T } = {};
for (const key of Object.keys(object)) {
const description = 'checking key ' + JSON.stringify(key);
result[key] = AssertEnter(description, () => of(object[key]));
}
return result;
};
}
/**
* Makes a check for a value that can be missing. Undefined, and null from JSON, both come out as
* undefined, so the rest of the application only ever has to check for undefined.
*
* @param of The check for a value that's there
* @returns A check that takes the value and returns undefined, or what the 'of' check returns
*/
function AssertOptional<T>(of: Check<T>): Check<T | undefined> {
return (value: unknown) => {
if (value === undefined || value === null) {
return undefined;
}
return of(value);
};
}
/**
* Makes a check for a string that must be one of a fixed set, giving it a string literal type.
*
* @param options The strings allowed, e.g. ['viewer', 'editor'] as const
* @returns A check that takes the value and returns it as one of the options
*/
function AssertOneOf<T extends string>(options: readonly T[]): Check<T> {
return (value: unknown) => {
const text = AssertString(value);
const option = options.find((option) => option === text);
if (option === undefined) {
const allowed = options.join(', ');
AssertFail('Expected one of ' + allowed + '; got ' + Describe(value));
}
return option;
};
}
export const Assert = {
Enter: AssertEnter,
Fail: AssertFail,
Check: AssertCheck,
String: AssertString,
Number: AssertNumber,
Integer: AssertInteger,
Boolean: AssertBoolean,
Object: AssertObject,
Array: AssertArray,
Dictionary: AssertDictionary,
Optional: AssertOptional,
OneOf: AssertOneOf,
};