Last active
April 4, 2025 17:34
-
-
Save YektaDev/aee5b9996f5418a84376c837975293c7 to your computer and use it in GitHub Desktop.
A simple, type-safe assertion utility for JavaScript/TypeScript.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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