Custom assertions
assertcheck exports its formatting primitives so you can build domain-specific assertions that produce the exact same output as the built-in ones — same block, same colours, same opts contract.
When to write a custom assertion
The built-in assertions cover generic invariants. Write a custom one when a domain-specific invariant appears in several places and deserves a name:
assertValidOrderId— "must be a string inord_*format"assertSupportedCurrency— "must be one of the ISO 4217 codes we accept"assertValidEmail— "must look like an email address"assertPositiveInteger— "must be an integer strictly greater than 0"
assert.string(id) followed by a manual regex test works, but assertValidOrderId(id) is self-documenting and produces a richer error.
The recipe
A custom assertion does three things: return early on success, build a formatted message, then print it and throw AssertionError.
import { AssertionError, buildBlock, color, fmtValue, output, parseOpts } from "assertcheck"
import type { AssertOptions } from "assertcheck"
export function assertPositiveInteger(
value: unknown,
opts?: string | AssertOptions
): asserts value is number {
if (typeof value === "number" && Number.isInteger(value) && value > 0) return
const o = parseOpts(opts)
const message = buildBlock({
assertion: "positiveInteger",
title: o.msg ?? "Expected a positive integer",
rows: [
{ label: "expected", value: "integer > 0", indicator: color.added("+") },
{ label: o.actual ?? "received", value: fmtValue(value), indicator: color.removed("✗") },
],
note: o.note,
})
output(message)
throw new AssertionError({
assertion: "positiveInteger",
message,
actual: value,
expected: "positive integer",
})
}Output:
══════════════════ ● Expected a positive integer ══════════════
── values ──────────────────────────────────────────────────────
+ expected integer > 0
✗ received -3
════════════════════════════════════════════════════════════════Honour the three opts fields
parseOpts normalises the string-or-object argument into { msg, actual, note }. Use msg as the title, actual as the label of the received row, and note at the bottom — that is what users of the built-in assertions expect.
A domain-specific example: order ID format
import { AssertionError, buildBlock, color, fmtValue, output, parseOpts } from "assertcheck"
import type { AssertOptions } from "assertcheck"
const ORDER_ID_PATTERN = /^ord_[a-z0-9]{16}$/
export function assertValidOrderId(
value: unknown,
opts?: string | AssertOptions
): asserts value is string {
if (typeof value === "string" && ORDER_ID_PATTERN.test(value)) return
const o = parseOpts(opts)
const message = buildBlock({
assertion: "validOrderId",
title: o.msg ?? "Invalid order ID format",
rows: [
{ label: "expected format", value: "ord_<16 chars>", indicator: color.added("+") },
{ label: o.actual ?? "received", value: fmtValue(value), indicator: color.removed("✗") },
],
note: o.note ?? "Order IDs are generated by createOrder() — do not construct them manually.",
})
output(message)
throw new AssertionError({ assertion: "validOrderId", message, actual: value, expected: "valid order ID" })
}Usage:
assertValidOrderId(orderId, {
msg: "orderId must be a valid order ID",
actual: "payload.orderId",
note: "check that the ID comes from the orders service",
})Available primitives
| Export | Purpose |
|---|---|
parseOpts(opts) | Normalise string | AssertOptions | undefined into { msg, actual, note } |
buildBlock(def) | Build the formatted block from a BlockDef — title, rows, optional diff, extras, note |
fmtValue(v) | Format any value for display: quoting, nesting, truncation, type labels |
diffObjects(actual, expected) | Produce diff rows for two objects — pass them through BlockDef.diff |
color | Colour helpers used by the indicators: color.added, color.removed, color.index, … |
output(message) | Print the block — ANSI on TTY, plain text in pipes, console.groupCollapsed in browsers |
AssertionError | The error class to throw, carrying assertion, actual, expected |
fail() is internal and is not exported. Calling output() then throwing AssertionError is the public equivalent.
Structural diff in a custom assertion
Pass a diff block instead of rows when you compare two structures:
const message = buildBlock({
assertion: "matchesSnapshot",
title: o.msg ?? "Response does not match snapshot",
diff: { actual: response, expected: snapshot },
note: o.note,
})Negation: assert.not instead of assertNotX
Rather than writing a separate assertNotPositiveInteger, wrap the positive one:
assert.not(assertPositiveInteger, value)
// passes if assertPositiveInteger would throw — value is NOT a positive integerassert.not works with any assertion function, built-in or custom.
Composing with built-in assertions
Custom assertions can call built-in ones for the low-level checks and add the domain layer on top:
export function assertValidUser(value: unknown): asserts value is User {
assert.object(value, "user must be an object")
assert.hasKeys(value, ["id", "email", "role"], "user is missing required fields")
assertValidEmail(value.email, {
msg: "user.email must be a valid email address",
note: "check the registration form validation",
})
}Because assert.object and assert.hasKeys narrow their argument, value.email is typed after the second line without a cast.