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,
};