tripwire

Test Support utilities, helpers, tools for testing in JavaScript and TypeScript.

View on GitHub

Expression Adapter Guide

The Expression Adapter is a powerful internal mechanism in Tripwire that allows you to compose assertion operations using a fluent, path-based expression syntax. It enables the creation of custom assertions by chaining operations together.

Overview

The createExprAdapter function creates a scope function that evaluates expressions against assertion instances. It parses dot-notation strings or arrays into a sequence of operations, allowing you to build complex assertion chains programmatically.

import { createExprAdapter } from '@nevware21/tripwire';

// Create an adapter for a simple "not" expression
const notAdapter = createExprAdapter("not", myAssertionFunction);

// Create an adapter for a chained expression
const deepEqualAdapter = createExprAdapter("deep.equal");

Expression Syntax

Basic Expressions

Expressions are written as dot-separated paths that represent the chain of operations to perform on an assertion instance.

// Simple single-step expression
createExprAdapter("not")           // Access .not property

// Multi-step expression
createExprAdapter("to.be")         // Access .to, then .be

// Complex chain
createExprAdapter("to.be.not")     // Access .to, then .be, then .not

Array Syntax

You can also pass expressions as an array of strings:

// Equivalent to "deep.equal"
createExprAdapter(["deep", "equal"])

// Equivalent to "not.deep.own.include"
createExprAdapter(["not", "deep", "own", "include"])

Function Calls with Arguments

Expressions can include function calls with arguments using parentheses. Arguments can be:

  1. Indexed arguments ({0}, {1}, …) - Reference arguments passed to the adapter by position
  2. Literal values ({text}) - Any braced token that is not an integer is passed as the literal string
  3. Named context arguments (bare name) - Reference values from the scope context
// Call a function with arguments from the adapter call
createExprAdapter("include({0})")

// Call with multiple arguments
createExprAdapter("is.within({0},{1})")

// Call with a named context value (scope.context.get("expected"))
createExprAdapter("equal(expected)")

Argument Reference Syntax

Arguments in function calls use specific syntax patterns:

Indexed Arguments: {0}, {1}, {2}, …

A braced non-negative integer references an argument passed to the adapter by its zero-based index; {0} is the first argument:

const adapter = createExprAdapter("is.above({0})");
// adapter.call(scope, 10) -> accesses is.above(10)
// The {0} is replaced with the first argument (10)

Named Context Arguments: name

A bare, un-braced token is looked up in the scope context at evaluation time:

const adapter = createExprAdapter("equal(expected)");
// Retrieves scope.context.get("expected") as the argument

A named argument may not contain spaces.

Literal String Values: {text}

Any braced argument that is not an integer is passed as the literal string between the braces:

const adapter = createExprAdapter("is.typeOf({string})");
// Passes the literal string "string" as the argument

Creating Custom Assertions

There are two ways to add custom assertion functions, each with different capabilities:

API Added To Can be called from expressions?
addAssertFunc assert object No - only callable directly via assert.myFunc()
addAssertInstFunc IAssertInst prototype Yes - callable from expressions and via expect().myFunc()

Basic Custom Assertion (addAssertFunc)

Use addAssertFunc to add functions directly to the assert object:

import { createExprAdapter, addAssertFunc, assert, IAssertScope } from '@nevware21/tripwire';

// Create an assertion that checks if a value is a positive number
const isPositiveFunc = function(this: IAssertScope): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value > 0,
        "expected {value} to be a positive number"
    );
    return this.that;
};

// Add it to the assert object
addAssertFunc(assert, 'isPositive', isPositiveFunc);
addAssertFunc(assert, 'isNotPositive', createExprAdapter("not", isPositiveFunc));

// Usage
assert.isPositive(5);      // Passes
assert.isNotPositive(-3);  // Passes
assert.isPositive(-1);     // Throws AssertionFailure

Instance Custom Assertions (addAssertInstFunc) - Callable from Expressions

Use addAssertInstFunc or addAssertInstFuncDefs to add functions to the IAssertInst prototype. These functions can be called from expressions:

import { 
    addAssertInstFunc, 
    addAssertInstFuncDefs,
    createExprAdapter, 
    expect,
    IAssertScope,
    IAssertInst,
    IExtendedAssertInst
} from '@nevware21/tripwire';

// Define a custom function
const isPositive = function(this: IAssertScope): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value > 0,
        "expected {value} to be a positive number"
    );
    return this.that;
};

// Add to the assertion instance prototype
addAssertInstFunc("isPositive", isPositive);

// Define the type extension
interface IMyAssertions {
    isPositive(): IAssertInst;
}

// Now you can use it directly via expect()
(expect(5) as IExtendedAssertInst<IMyAssertions>).isPositive();  // Passes

// AND it can be called from expressions!
const notPositiveAdapter = createExprAdapter("not.isPositive");
// This adapter will: access .not, then call .isPositive()

What to return. return this.that is the convention every built-in scope function follows, and is what the examples here do. Returning nothing is equivalent to returning a fresh instance for the same value, and return this.newInst(newValue) hands a different value on to whatever follows. Whichever you choose, a function that is called – directly or from an expression such as isDivisibleBy({0}) – runs exactly once with the arguments it was given.

Expression-Based Assertion

import { createExprAdapter, addAssertFunc, assert } from '@nevware21/tripwire';

// Create assertions using expressions
addAssertFunc(assert, 'isNumericString', createExprAdapter("is.string"));
addAssertFunc(assert, 'isFiniteNumber', createExprAdapter("is.finite"));
addAssertFunc(assert, 'exists', createExprAdapter("to.exist"));
addAssertFunc(assert, 'isArray', createExprAdapter("is.array"));

Assertion with Scope Function

The second parameter to createExprAdapter is an optional scope function that runs after the expression is evaluated:

const checkRange = function(this: IAssertScope, min: number, max: number): any {
    const value = this.context.value;
    this.context.eval(
        value >= min && value <= max,
        `expected {value} to be between ${min} and ${max}`
    );
    return this.that;
};

// Expression runs first, then scope function
const notInRangeAdapter = createExprAdapter("not", checkRange);

// Usage: first applies "not", then runs checkRange
addAssertFunc(assert, 'isNotInRange', { scopeFn: notInRangeAdapter, nArgs: 3 });

Expression Operations Reference

Chainable Operations

Each step in an expression must exist on the object returned by the previous step, so the available steps depend on where you are in the chain. The steps available directly on an assertion instance (IAssertInst) are:

Expression Description
not Negates the following assertion
and Language chain; continues after a called assertion (is.number().and.is.above(1)), keeping the chain’s modifiers
or(...) Passes when any of the given clause functions passes; see IAssertInst.or
to Language chain; exposes be, have, exist, ifError, throw, deep, strictly, include, equal and the change operations
is Type and numeric checks (is.string, is.above(...)), plus the a, an and at language chains
has Property and key operations (property, own, nested, all, any, lengthOf, sizeOf)
deep Enable deep equality comparison (equal, include, property, own, nested, keys, strictly)
own Check only own properties (include, property, propertyDescriptor, iterator)
any Match any key (any.keys, any.deep.keys); callable directly to match any value
all Match all keys (all.keys, all.deep.keys); callable directly to match all values
strictly Enable strict equality (equal, true, false)
include / includes / contain / contains Inclusion checks

Some steps only exist further down a chain:

Expression Available after Description
be to Language chain; same steps as is (to.be.true, to.be.above(...))
have to Language chain; same steps as has (to.have.property(...))
a / an / at is (or to.be) Language chains (is.at.least(...))
nested has or deep Nested (dotted path) property access
that / value property(...) Switch to asserting on the property’s value (and keeps asserting on the object)

Words such as been, which, with, of, same, but, does, still and also are not steps; using one throws an “Invalid step” error at evaluation time.

Type Checking Operations

Expression Description
is.string Check if value is a string
is.array Check if value is an array
is.number Check if value is a number
is.boolean Check if value is a boolean
is.function Check if value is a function
is.object Check if value is an object
is.null Check if value is null
is.undefined Check if value is undefined
is.nan Check if value is NaN
is.finite Check if value is finite

Function Operations (Require Arguments)

Expression Arguments Description
equal({0}) expected value Check equality
include({0}) expected value Check inclusion
is.above({0}) number Check if greater than (also is.gt, is.greaterThan)
is.below({0}) number Check if less than (also is.lt, is.lessThan)
is.least({0}) number Check if at least (also is.gte, is.greaterThanOrEqual)
is.most({0}) number Check if at most (also is.lte, is.lessThanOrEqual)
is.within({0},{1}) min, max Check if within range
is.instanceof({0}) constructor Check instance type (also is.instanceOf)
is.typeOf({0}) type string Check typeof
has.all.keys({0}) expected keys Check the object has all of the keys
has.any.keys({0}) expected keys Check the object has any of the keys

The numeric and type checks are also reachable through to.be (for example to.be.above({0})), and the key checks through to.have (to.have.all.keys({0})).

Combining Expressions

Negation Patterns

// Simple negation
createExprAdapter("not.is.string")      // isNotString
createExprAdapter("not.is.array")       // isNotArray
createExprAdapter("not.to.exist")       // notExists

// Deep negation
createExprAdapter("not.deep.equal")     // notDeepEqual
createExprAdapter("not.strictly.equal") // notStrictEqual

Modifier Patterns

// Deep operations
createExprAdapter("deep.equal")         // deepEqual
createExprAdapter("deep.include")       // deepInclude
createExprAdapter("deep.own.include")   // deepOwnInclude

// Own property operations
createExprAdapter("own.include")        // ownInclude
createExprAdapter("own.property")       // ownProperty

// Combined modifiers
createExprAdapter("not.deep.own.include") // notDeepOwnInclude
createExprAdapter("has.any.keys")         // hasAnyKeys
createExprAdapter("has.all.keys")         // hasAllKeys
createExprAdapter("has.any.deep.keys")    // hasAnyDeepKeys

Examples in Practice

Built-in Assert Functions Using Expressions

The tripwire assert class uses expressions internally:

// Type checking
isString: createExprAdapter("is.string"),
isNotString: createExprAdapter("not.is.string"),
isArray: createExprAdapter("is.array"),
isNotArray: createExprAdapter("not.is.array"),

// Existence
exists: createExprAdapter("to.exist"),
notExists: createExprAdapter("not.to.exist"),

// NaN/Finite
isNaN: createExprAdapter("is.nan"),
isNotNaN: createExprAdapter("not.is.nan"),
isFinite: createExprAdapter("is.finite"),
isNotFinite: createExprAdapter("not.is.finite"),

// Include operations
ownInclude: createExprAdapter("own.include"),
notOwnInclude: createExprAdapter("not.own.include"),
deepOwnInclude: createExprAdapter("deep.own.include"),
notDeepOwnInclude: createExprAdapter("not.deep.own.include"),

// Keys operations
hasAnyKeys: createExprAdapter("has.any.keys"),
hasAllKeys: createExprAdapter("has.all.keys"),
doesNotHaveAnyKeys: createExprAdapter("not.has.any.keys"),
hasAnyDeepKeys: createExprAdapter("has.any.deep.keys"),

Custom Assertion Examples

String Validation Assertion

import { createExprAdapter, addAssertFunc, assert, IAssertScope } from '@nevware21/tripwire';

// Custom scope function to check string length
const hasMinLengthFunc = function(this: IAssertScope, minLength: number): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'string' && value.length >= minLength,
        `expected {value} to have at least ${minLength} characters`
    );
    return this.that;
};

// Register positive and negative versions
addAssertFunc(assert, 'hasMinLength', { scopeFn: hasMinLengthFunc, nArgs: 2 });
addAssertFunc(assert, 'notHasMinLength', { 
    scopeFn: createExprAdapter("not", hasMinLengthFunc), 
    nArgs: 2 
});

// Usage
assert.hasMinLength("hello", 3);     // Passes
assert.notHasMinLength("hi", 5);     // Passes
assert.hasMinLength("ab", 5);        // Throws - "ab" has only 2 chars

Date Validation Assertion

const isAfterDateFunc = function(this: IAssertScope, date: Date): any {
    const value = this.context.value;
    this.context.eval(
        value instanceof Date && value > date,
        `expected {value} to be after ${date.toISOString()}`
    );
    return this.that;
};

addAssertFunc(assert, 'isAfter', { scopeFn: isAfterDateFunc, nArgs: 2 });
addAssertFunc(assert, 'isNotAfter', { 
    scopeFn: createExprAdapter("not", isAfterDateFunc), 
    nArgs: 2 
});

// Usage
const now = new Date();
const yesterday = new Date(Date.now() - 86400000);
assert.isAfter(now, yesterday);      // Passes
assert.isNotAfter(yesterday, now);   // Passes

Calling Custom Instance Functions from Expressions

When you add functions using addAssertInstFunc or addAssertInstFuncDefs, those functions become available in the assertion instance prototype and can be called from expressions. This is a powerful pattern for creating reusable assertion building blocks.

Complete Example: Custom Validation Library

import { 
    addAssertInstFunc, 
    addAssertInstFuncDefs,
    createExprAdapter,
    addAssertFunc,
    assert,
    expect,
    IAssertScope,
    IAssertInst,
    IExtendedAssertInst
} from '@nevware21/tripwire';

// Step 1: Define custom assertion functions
const isPositive = function(this: IAssertScope): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value > 0,
        "expected {value} to be a positive number"
    );
    return this.that;
};

const isEven = function(this: IAssertScope): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value % 2 === 0,
        "expected {value} to be an even number"
    );
    return this.that;
};

const isInRange = function(this: IAssertScope, min: number, max: number): any {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value >= min && value <= max,
        `expected {value} to be between ${min} and ${max}`
    );
    return this.that;
};

// Step 2: Add functions to the assertion instance prototype
// These will be available for expressions to call!
addAssertInstFuncDefs({
    isPositive: { scopeFn: isPositive },
    isEven: { scopeFn: isEven },
    isInRange: { scopeFn: isInRange }
});

// Step 3: Define TypeScript interface for type safety
interface IMyCustomAssertions {
    isPositive(): IAssertInst;
    isEven(): IAssertInst;
    isInRange(min: number, max: number): IAssertInst;
}

// Step 4: Create expressions that use the custom functions!
// These expressions call our custom instance functions
const isNotPositive = createExprAdapter("not.isPositive");
const isNotEven = createExprAdapter("not.isEven");
const isNotInRange = createExprAdapter("not.isInRange");

// The expression "not.isPositive" will:
// 1. Access .not (applies negation)
// 2. Call .isPositive() (our custom function)

// Step 5: Add convenience functions to assert object
addAssertFunc(assert, 'isPositive', isPositive);
addAssertFunc(assert, 'isNotPositive', { scopeFn: isNotPositive, nArgs: 1 });
addAssertFunc(assert, 'isEven', isEven);
addAssertFunc(assert, 'isNotEven', { scopeFn: isNotEven, nArgs: 1 });
addAssertFunc(assert, 'isInRange', { scopeFn: isInRange, nArgs: 3 });
addAssertFunc(assert, 'isNotInRange', { scopeFn: isNotInRange, nArgs: 3 });

// Usage via assert
assert.isPositive(5);           // Passes
assert.isNotPositive(-3);       // Passes (negated - negative is not positive)
assert.isEven(4);               // Passes
assert.isNotEven(3);            // Passes (negated - 3 is not even)
assert.isInRange(5, 1, 10);     // Passes
assert.isNotInRange(15, 1, 10); // Passes (negated - 15 is not in 1-10)

// Usage via expect with chaining
type MyExpect = IExtendedAssertInst<IMyCustomAssertions>;
(expect(5) as MyExpect).isPositive().isEven();  // Fails - 5 is not even
(expect(4) as MyExpect).isPositive().isEven();  // Passes - 4 is positive AND even
(expect(4) as MyExpect).not.isEven();           // Fails - 4 IS even
(expect(3) as MyExpect).not.isEven().isPositive(); // Passes - 3 is NOT even AND is positive

Creating Composable Assertion Chains

Custom instance functions enable powerful composable assertions:

import { addAssertInstFunc, createExprAdapter, expect, IAssertScope } from '@nevware21/tripwire';

// Add a custom "isPrime" function to the instance prototype
addAssertInstFunc("isPrime", function(this: IAssertScope) {
    const value = this.context.value;
    let isPrime = typeof value === 'number' && value > 1;
    if (isPrime) {
        for (let i = 2; i <= Math.sqrt(value); i++) {
            if (value % i === 0) {
                isPrime = false;
                break;
            }
        }
    }
    this.context.eval(isPrime, "expected {value} to be a prime number");
    return this.that;
});

// Now "isPrime" can be used in expressions!
// Create complex assertion adapters
const notPrimeAdapter = createExprAdapter("not.isPrime");

// Usage
expect(7).isPrime();      // Passes - 7 is prime
expect(4).not.isPrime();  // Passes - 4 is not prime

Expressions with Arguments to Custom Functions

Custom functions that take arguments can also be called from expressions:

import { addAssertInstFunc, createExprAdapter, expect, IAssertScope } from '@nevware21/tripwire';

// Custom function that takes an argument
addAssertInstFunc("isDivisibleBy", function(this: IAssertScope, divisor: number) {
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value % divisor === 0,
        `expected {value} to be divisible by ${divisor}`
    );
    return this.that;
});

// Create an expression adapter that calls the custom function with an argument
// {0} refers to the first argument passed to the adapter
const notDivisibleByAdapter = createExprAdapter("not.isDivisibleBy({0})");

// Usage
expect(10).isDivisibleBy(5);  // Passes
expect(10).isDivisibleBy(3);  // Fails

// Using the adapter
// notDivisibleByAdapter.call(scope, 3) -> expect(value).not.isDivisibleBy(3)

Important Limitations

When using custom instance functions in expressions, there are some important limitations to be aware of:

Modifiers Cannot Precede Custom Functions (except not)

Custom functions added via addAssertInstFunc can only be called directly or after the not modifier. Modifiers like deep, own, and strictly do not support custom functions:

// ✅ Works: Direct call
createExprAdapter("myCustomFunc");

// ✅ Works: not modifier followed by custom function
createExprAdapter("not.myCustomFunc");

// ❌ Throws: deep modifier cannot be followed by custom function
createExprAdapter("deep.myCustomFunc");  // Invalid step: myCustomFunc

// ❌ Throws: own modifier cannot be followed by custom function  
createExprAdapter("own.myCustomFunc");   // Invalid step: myCustomFunc

// ❌ Throws: Combined modifiers don't work with custom functions
createExprAdapter("not.deep.myCustomFunc"); // Invalid step: myCustomFunc

This is because modifiers like deep and own return specialized interfaces (IDeepOp, IOwnOp) that only expose specific built-in operations, not the full assertion instance.

Workaround: Handle Modifiers Inside Custom Functions

If you need modifier behavior in custom functions, perform it within the function itself. Note that there is no flag you can set on the context to turn on deep comparison – the deep step works by exposing deep-aware versions of the built-in operations, so a custom function has to do its own deep comparison (or delegate to a built-in one):

addAssertInstFunc("myDeepEqual", function(this: IAssertScope, expected: any) {
    // Delegate to the built-in deep.equal (or perform your own deep comparison here)
    this.that.deep.equal(expected);
    // ... any additional checks
    return this.that;
});

// Now use directly without deep modifier
createExprAdapter("myDeepEqual");

When the deep behavior you need is already a built-in operation, use the deep step in the expression instead of a custom function: createExprAdapter("deep.equal").

Accessing Context Value in Custom Functions

The recommended way to access values in custom functions is through this.context.value:

addAssertInstFunc("isPositive", function(this: IAssertScope) {
    // Access the value directly from context
    const value = this.context.value;
    this.context.eval(
        typeof value === 'number' && value > 0,
        "expected {value} to be a positive number"
    );
    return this.that;
});

Performance Considerations

Expression Parsing

Expressions are parsed once when createExprAdapter is called, not on every invocation. This means:

// Good: Parse once, reuse many times
const adapter = createExprAdapter("deep.equal");
for (let i = 0; i < 1000; i++) {
    adapter.call(scope, expectedValue);
}

// The expression "deep.equal" is only parsed once

The notAdapter Optimization

For simple “not” operations with a scope function, tripwire provides an optimized createNotAdapter:

import { createNotAdapter, createExprAdapter } from '@nevware21/tripwire';

// Optimized - no expression parsing overhead
const optimized = createNotAdapter(myScopeFn);

// Standard - parses "not" expression
const standard = createExprAdapter("not", myScopeFn);

// Both produce equivalent behavior, but createNotAdapter is slightly faster

Use createNotAdapter when you only need negation with a scope function.

Error Handling

Invalid Expression Errors

Invalid expressions throw AssertionError at adapter creation time:

// Invalid: nested parentheses
createExprAdapter("func((arg))")  // Throws AssertionError

// Invalid: mismatched parentheses
createExprAdapter("func(arg")     // Throws AssertionError

// Invalid: multiple argument sections
createExprAdapter("func()()")     // Throws AssertionError

Step Resolution Errors

If a step doesn’t exist on the object the previous step returned, an AssertionError is thrown when the adapter is evaluated. The message gives the index of the failing step, the full step path, the steps that were available at that point, and the current value:

const adapter = createExprAdapter("nonexistent.step");
// Runtime error:
// "0 Invalid step: nonexistent for [nonexistent->step] available steps: [not;deep;own;...] - <value>"

TypeScript Support

The expression adapter provides full TypeScript support:

import { createExprAdapter, IScopeFn, IAssertScope } from '@nevware21/tripwire';

// Type-safe scope function
const typedScopeFn: IScopeFn = function<R>(this: IAssertScope, arg1: string, arg2: number): R {
    // Full type information available
    const value = this.context.value;
    this.context.eval(true, "assertion message");
    return this.that;
};

// Returns IScopeFn
const adapter: IScopeFn = createExprAdapter("not", typedScopeFn);

See Also