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:
- Indexed arguments (
{0},{1}, …) - Reference arguments passed to the adapter by position - Literal values (
{text}) - Any braced token that is not an integer is passed as the literal string - 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);