Skip to content

Instantly share code, notes, and snippets.

@YektaDev
Last active April 4, 2025 17:34
Show Gist options
  • Save YektaDev/aee5b9996f5418a84376c837975293c7 to your computer and use it in GitHub Desktop.
Save YektaDev/aee5b9996f5418a84376c837975293c7 to your computer and use it in GitHub Desktop.
A simple, type-safe assertion utility for JavaScript/TypeScript.
/*
* Assertion.ts - A simple, type-safe assertion utility for JavaScript/TypeScript.
* https://gist.github.com/YektaDev/aee5b9996f5418a84376c837975293c7
*
* The Assert class provides a robust set of methods to validate various data types, structures,
* and values. It simplifies runtime checks and ensures that the program behaves as expected by
* throwing detailed `AssertionError` exceptions when validation fails.
*
* ---
*
* MIT License
*
* Copyright (c) 2024 Ali Khaleqi Yekta
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const FORMAT_STACKTRACE = true;
/**
* Creates a new asserter.
*
* @param label - The label of the assertion messages.
* @param context - An optional context to provide additional information about the errors.
*/
export function asserter(label: string, context?: () => string): Asserter {
const instance = new Assertions(label, context);
const onInvoke = (predicate: any, e: string, v?: unknown) => {
if (!predicate) instance.e(e, v);
};
// Copy all instance methods and properties to the invokable wrapper
const result = Object.setPrototypeOf(onInvoke, instance) as Asserter;
result.label = label;
result.context = context;
return result;
}
export type Falsy = false | 0 | 0n | "" | null | undefined;
export interface Asserter extends Assertions {
/**
* Asserts that the predicate is `true`. Throws an `AssertionError` if it isn't.
*
* @param predicate - The predicate to assert.
* @param e - The error message to throw if the predicate is `false`.
* @param v - The optional actual value of the assertion to include in the error message.
* @throws AssertionError
*/
(predicate: any, e: string, v?: unknown): asserts predicate;
}
class Assertions {
public label: string;
public context?: () => string;
constructor(label: string, context?: () => string) {
this.label = label;
this.context = context;
}
/**
* Logs a warning message.
*
* @param w - The warning message.
* @param v - The optional actual value to include in the warning message.
*/
public w(w: string, v?: unknown | undefined): void {
const wrn = `${w}${v !== undefined ? " " + actual(v) : ""}${this.context ? "\n" + this.context() : ""}`;
console.warn(`[${this.label}]: ` + wrn);
}
/**
* Throws an `AssertionError` with the specified error message.
*
* @param e - The error message.
* @param v - The optional actual value to include in the error message.
*/
public e(e: string, v?: unknown | undefined): never {
const err = `${e}${v !== undefined ? " " + actual(v) : ""}${this.context ? "\n\n" + this.context() : ""}`;
throw new AssertionError(`❌ **[${this.label}]:** ` + err);
}
/**
* Asserts that the value is of the specified type.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param type - The type to assert.
* @returns The value as the specified type.
*/
public type(n: string, v: unknown, type: string): unknown {
this.defined(n, v);
const t = type.toLowerCase();
if ((typeof v).toLowerCase() !== t) this.e(`\`${n}\` must be ${article(t)} \`${t}\`.`, v);
return v;
}
/**
* Asserts that the value is truthy.
*
* @template T - The type of the value.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The truthy value.
*/
public truthy<T>(n: string, v: T | Falsy): asserts v is T {
if (!v) this.e(`\`${n}\` is required.`, v);
}
/**
* Asserts that the value is defined.
*
* @template T - The type of the value.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
*/
public defined<T>(n: string, v: T | undefined): asserts v is T {
if (v === undefined) this.e(`\`${n}\` is required.`, v);
}
/**
* Asserts that the value is neither null nor undefined.
*
* @template T - The type of the value.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value if it has a value.
*/
public notNullOrUndefined<T>(n: string, v: T | null | undefined): asserts v is T {
if (v === null || v === undefined) this.e(`\`${n}\` is required to have a value.`, v);
}
/**
* Asserts that the value is a number.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param min - The optional minimum value.
* @param max - The optional maximum value.
* @returns The value as a number.
*/
public number(n: string, v: unknown, min?: number, max?: number): number {
if (min !== undefined && max !== undefined && min > max) {
this.e("[number]: `min` must be less than or equal to `max`.", { min, max });
}
this.notNullOrUndefined(n, v);
if (typeof v !== "number" || Number.isNaN(v)) this.e(`\`${n}\` must be a valid number.`, v);
const num = v as number;
if ((min !== undefined && num < min) || (max !== undefined && num > max)) {
this.e(`\`${n}\` must be in the range [${min ?? "-∞"}, ${max ?? "+∞"}].`, v);
}
return num;
}
/**
* Asserts that the value is an integer.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param min - The optional minimum value.
* @param max - The optional maximum value.
*/
public integer(n: string, v: unknown, min?: number, max?: number): number {
const num = this.number(n, v, min, max);
if (!Number.isInteger(num)) this.e(`\`${n}\` must be an integer.`, v);
return num;
}
/**
* Asserts that the value is a positive number.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a positive number.
*/
public positiveNumber(n: string, v: unknown): number {
return this.number(n, v, Number.MIN_VALUE);
}
/**
* Asserts that the value is a non-negative number.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a non-negative number.
*/
public nonNegativeNumber(n: string, v: unknown): number {
return this.number(n, v, 0);
}
/**
* Asserts that the value is a string.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a string.
*/
public string(n: string, v: unknown): string {
return this.type(n, v, "string") as string;
}
/**
* Asserts that the value is a non-empty string.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a non-empty string.
*/
public nonEmptyString(n: string, v: unknown): string {
this.defined(n, v);
if (typeof v !== "string" || v.length === 0) this.e(`\`${n}\` must be a non-empty string.`, v);
return v as string;
}
/**
* Asserts that the value is a non-empty string without leading or trailing whitespace.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a non-empty, trimmed string.
*/
public nonEmptyTrimmedString(n: string, v: unknown): string {
const s = this.nonEmptyString(n, v);
if (s.trim() !== s) this.e(`\`${n}\` must not have leading or trailing whitespace.`, v);
return s;
}
/**
* Asserts that the value is a valid pathname.
* A pathname must start with a "/".
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a pathname.
*/
public pathname(n: string, v: unknown): string {
const s = this.nonEmptyTrimmedString(n, v);
if (!s.startsWith("/")) this.e(`\`${n}\` must be a pathname (must start with a "/").`, v);
return s;
}
/**
* Asserts that the value is an object.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as an object.
*/
public object(n: string, v: unknown): object {
this.truthy(n, v);
return this.type(n, v, "object") as object;
}
/**
* Asserts that the value is a boolean.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a boolean.
*/
public boolean(n: string, v: unknown): boolean {
return this.type(n, v, "boolean") as boolean;
}
/**
* Asserts that the value is an array.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as an array.
*/
public array<T>(n: string, v: T[] | undefined): asserts v is T[] {
this.defined(n, v);
if (!Array.isArray(v)) this.e(`\`${n}\` must be an array.`, v);
}
/**
* Asserts that the value is a non-empty array.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a non-empty array.
*/
public nonEmptyArray<T>(n: string, v: T[] | undefined): T[] {
this.defined(n, v);
if (!Array.isArray(v) || v.length === 0) this.e(`\`${n}\` must be a non-empty array.`, v);
return v;
}
/**
* Asserts that the value is a non-empty array of a specific type.
*
* @template T - The type of the array elements.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param type - The type to assert.
*/
public nonEmptyArrayOfType<T>(n: string, v: T[] | undefined, type: string): T[] {
const va = this.nonEmptyArray(n, v);
const t = type.toLowerCase();
const error = `All elements in \`${n}\` must be of type \`${t}\`.`;
if (t === "object") {
if (!va.every((item) => typeof item === "object" && item !== null)) this.e(error, v);
} else {
if (!va.every((item) => (typeof item).toLowerCase() === t)) this.e(error, v);
}
return va;
}
/**
* Asserts that the value is a valid date.
*
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @returns The value as a valid Date.
*/
public date(n: string, v: unknown): Date {
this.truthy(n, v);
if (!(v instanceof Date)) this.e(`\`${n}\` must be a date.`, v);
const d = v as Date;
if (isNaN(d.getDate())) this.e(`\`${n}\` must be a valid date.`, d);
return d;
}
/**
* Asserts that the defined non-null value matches one of the provided options.
*
* @template T - The type of the value.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param options - The set of valid options.
* @returns The value if it matches one of the options.
*/
public either<T>(n: string, v: T, ...options: T[]): T {
this.notNullOrUndefined(n, v);
if (!options.some((option) => this.deepEqual(v, option))) {
this.e(`\`${n}\` must be one of the following: ${options.join(", ")}.`, v);
}
return v;
}
/**
* Asserts that a value is an instance of the provided class.
*
* @template T - The expected type of the instance.
* @param n - The name of the variable being checked (for error reporting).
* @param v - The value to assert.
* @param className - The class constructor to check the instance against.
* @returns The value cast to the specified type `T` if validation passes.
*
* @example
* const validator = new Validator();
* const myDate = new Date();
* const validatedDate = validator.instanceOf('myDate', myDate, Date);
* console.log(validatedDate.toISOString()); // Will print ISO string of `myDate` if validation passes.
*/
public instanceOf<T>(n: string, v: unknown, className: new (...args: any[]) => T): T {
this.truthy(n, v);
if (!(v instanceof className)) this.e(`\`${n}\` must be an instance of ${className.name}.`, v);
return v as T;
}
private deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => this.deepEqual(a[key], b[key]));
}
}
const article = (before: string) => (/^[aeiou]/i.test(before) ? "an" : "a");
function actual(value: unknown): string {
if (value === undefined) return "Received `undefined`";
if (value === null) return "Received `null`";
const t = typeof value;
const prefix = `Received ${article(t)} \`${t}\` with the value: `;
const content = t === "object" ? "\n" + JSON.stringify(value, null, 2) : `\`${JSON.stringify(value)}\``;
return prefix + content;
}
class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = "AssertionError";
if (FORMAT_STACKTRACE && this.stack) {
this.stack = AssertionError.simplifyPaths(AssertionError.formatAssertionLines(this.stack));
}
}
private static getNeededStacktraceLines(stacktrace: string): string[] | undefined {
const lines = stacktrace.split("\n");
if (!stacktrace.includes("/Assertion.ts:") || !lines[0].includes("AssertionError:")) {
// + The stack trace must contain a reference to Assertion.ts.
// + The first line of the stack trace must be an AssertionError.
// Instead of throwing a new error, we simply give up on error formatting.
return undefined;
}
return lines.slice(lines.findIndex((line) => line.includes("/Assertion.ts:")));
}
private static formatAssertionLines(stacktrace: string): string {
const lines = AssertionError.getNeededStacktraceLines(stacktrace);
if (!lines) return stacktrace;
const stackStartIndex = lines.findIndex((line) => !line.includes("/Assertion.ts:"));
const stacktraceContent = lines.slice(stackStartIndex).join("\n").trim();
const items = lines
.slice(0, stackStartIndex)
.filter((line) => line.includes("at Assertions.") || line.includes("at Function."))
.map((line) => line.substring(line.indexOf(".") + 1, line.indexOf("(")).trim())
.reverse()
.filter((line) => line.length !== 0 && line !== "e" && line !== "w");
if (items.length === 0) return stacktraceContent;
const assertion = `Failed Assertion: ${items.join(" → ")} ❌`;
return `${assertion}\n\n${stacktraceContent}`;
}
private static simplifyPaths(stacktrace: string, startLine: number = 3): string {
const lines = stacktrace.trim().split("\n");
const first = lines.slice(0, startLine).join("\n");
const rest = lines.slice(startLine).join("\n");
const escapedProjectRoot = process.cwd().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return `${first}\n${rest.replace(new RegExp(`(file://)?${escapedProjectRoot}`, "g"), "~")}`;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment