Skip to content

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 in ord_* 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.

ts
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 ​

ts
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:

ts
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 ​

ExportPurpose
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
colorColour 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
AssertionErrorThe 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:

ts
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:

ts
assert.not(assertPositiveInteger, value)
// passes if assertPositiveInteger would throw — value is NOT a positive integer

assert.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:

ts
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.

Released under the Apache 2.0 License. Built by Vagabond Studio — senior-only for growing companies.