Tiny when-clause expression evaluator with full type-safty
A tiny, zero-dependency evaluator for when-clause expressions — a mini expression language for conditionally enabling UI, commands, and features based on a context object.
Inspired by VS Code’s when clause contexts.
Type-safe: expression strings are statically validated against your context type at the call site. Typos and unknown keys surface as TypeScript errors before the code runs.
pnpm add whenexpr
import { evaluateWhen } from 'whenexpr'
interface Context {
mode: 'development' | 'production'
debug: boolean
verbose: boolean
}
const ctx: Context = {
mode: 'development',
debug: true,
verbose: false,
}
evaluateWhen('debug && mode == development', ctx) // true
evaluateWhen('verbose || mode == production', ctx) // false
evaluateWhen('!verbose', ctx) // true
// Typos are caught at compile time:
evaluateWhen('degub && mode == development', ctx)
// ^^^^^^^ Type error: Unknown context key: "degub"
whenexpr supports a JavaScript-expression subset: logical, equality, relational, and arithmetic operators, plus parentheses for grouping.
| Kind | Example |
|---|---|
| Boolean | true, false |
| Number | 42, 1.5 |
| String | "dev", 'dev' |
| Category | Operators | Example |
|---|---|---|
| Unary | !, -, + |
!verbose, -x |
| Logical | &&, || |
debug && !verbose |
| Equality | ==, !=, ===, !== |
mode == development, x === 1 |
| Relational | <, <=, >, >= |
x >= 10 |
| Arithmetic | +, -, *, /, % |
a + b * c |
| Grouping | ( … ) |
(a || b) && c |
|| → && → equality → relational → + - → * / % → unary → primary
== vs === semantics== / != follow the original VS Code when-clause idiom: the right-hand side is a single value token (bare identifier, quoted string, number, or boolean), compared as a string.
evaluateWhen('mode == development', { mode: 'development' }) // true
evaluateWhen('count == 5', { count: 5 }) // true — stringified
=== / !== follow JavaScript strict equality. Both sides are full expressions.
evaluateWhen('x === 1', { x: 1 }) // true
evaluateWhen('x === 1', { x: '1' }) // false — no coercion
// Conditional UI visibility
evaluateWhen('debug && !verbose', ctx)
// Either/or
evaluateWhen('mode == development || mode == staging', ctx)
// Arithmetic + strict comparison
evaluateWhen('(((a || b) && c) + foo) === 1', {
a: false,
b: true,
c: true,
foo: 0,
}) // true
// Ranges
evaluateWhen('x >= 10 && x < 100', { x: 42 }) // true
Keys can be namespaced using . or : separators to avoid collisions between unrelated features or plugins:
// Flat keys (recommended)
const ctx = {
'vite.mode': 'development',
'vite:buildMode': 'lib',
}
// Nested objects (also supported)
const ctx = {
vite: { mode: 'development', ssr: true },
}
Both styles work in expressions:
evaluateWhen('vite.mode == development', ctx)
evaluateWhen('vite:buildMode == lib', ctx)
evaluateWhen('vite.ssr', ctx)
When resolving a namespaced key like vite.mode:
ctx['vite.mode'] firstctx.vite?.modeFlat keys take priority over nested objects if both exist.
When ctx is typed with known keys, evaluateWhen parses and validates the expression string in the TypeScript type system. Unknown keys and syntax errors become type errors at the call site — no runtime check needed, no strict flag to remember.
interface Context {
mode: 'development' | 'production'
debug: boolean
editor: { lang: string }
}
declare const ctx: Context
evaluateWhen('debug && mode == development', ctx) // ok
evaluateWhen('editor.lang == ts', ctx) // ok — nested path
evaluateWhen('debug || unknownKey', ctx)
// ^^^^^^^^^^ Unknown context key: "unknownKey"
evaluateWhen('debug &&& verbose', ctx)
// ^^^^^^^^^^^^^^^^^^^^ Syntax error
Validation is layered so you pay only for what you need:
ctx type |
expression |
Syntax check | Key check |
|---|---|---|---|
Specific (interface Ctx { … }) |
literal | ✓ | ✓ |
Wide (object, {}, Record<string, unknown>, any, unknown) |
literal | ✓ | — |
| anything | string variable |
— | — |
So a wide context still catches (unbalanced or debug &&& verbose — you just don’t need to enumerate every key. And a dynamic string expression opts out entirely.
const loose: Record<string, unknown> = {}
evaluateWhen('anything && at-all', loose) // ok — keys not checked
evaluateWhen('(unbalanced', loose) // type error: syntax
const expr: string = loadExprFromDisk()
evaluateWhen(expr, ctx) // ok — no validation at all
define helpersLibraries that expose defineCommand, defineAction, etc. can carry the validation into their own APIs with the WhenExpression<Ctx, S> helper:
import type { WhenExpression } from 'whenexpr'
interface AppCtx {
mode: 'dev' | 'prod'
debug: boolean
}
function defineCommand<const W extends string>(cmd: {
name: string
title: string
when?: WhenExpression<AppCtx, W>
}): typeof cmd {
return cmd
}
defineCommand({ name: 'toggle', title: 'Toggle', when: 'debug && mode == dev' })
defineCommand({ name: 'x', title: 'X', when: 'typo' })
// ^^^^^^ type error
The const W extends string captures the literal so WhenExpression can validate it. Runtime behavior is unchanged — the helper is purely a type.
evaluateWhen(expression, ctx, options?)Evaluate a when-clause expression against a context object. Returns boolean.
function evaluateWhen<T extends object, const E extends string>(
expression: E & ValidateExpression<E, T>,
ctx: T,
options?: { strict?: boolean },
): boolean
The ValidateExpression<E, T> in the signature is what performs static checking — see Type Safety above.
By default, unknown context keys evaluate to undefined (falsy). Pass { strict: true } to throw an error instead — useful for catching typos or stale expressions during development:
evaluateWhen('unknownKey', {}, { strict: true })
// → Error: Unknown context key: "unknownKey"
Short-circuit evaluation still applies, so keys that aren’t reached are not checked:
// `debug` is truthy, so `unknownKey` is never evaluated — no throw
evaluateWhen('debug || unknownKey', { debug: true }, { strict: true })
parse(expression) and evaluate(node, ctx, options?)Under the hood, evaluateWhen is evaluate(parse(expression), ctx, options). You can split the two steps to avoid re-parsing when the same expression is evaluated against many contexts:
import { evaluate, parse } from 'whenexpr'
const node = parse('debug && mode == development')
evaluate(node, { debug: true, mode: 'development' }) // true
evaluate(node, { debug: true, mode: 'production' }) // false
parse returns a WhenNode — a discriminated union you can also inspect or transform:
type WhenNode
= | { type: 'literal', value: boolean | number | string }
| { type: 'key', key: string }
| { type: 'unary', op: UnaryOp, operand: WhenNode }
| { type: 'binary', op: BinaryOp, left: WhenNode, right: WhenNode }
type UnaryOp = '!' | '-' | '+'
type BinaryOp
= | '||' | '&&'
| '==' | '!=' | '===' | '!=='
| '<' | '>' | '<=' | '>='
| '+' | '-' | '*' | '/' | '%'
resolveContextValue(key, ctx)Resolve a single context key (including namespaced keys) from the context object. Returns unknown, or undefined if the key is not found.
function resolveContextValue<T extends Record<string, unknown>>(
key: string,
ctx: T,
): unknown
| Type | Purpose |
|---|---|
WhenExpression<T, S> |
Branded expression type for use in your own define-style helpers (see Type Safety). |
ValidateExpression<S, T> |
Lower-level: returns S on success, WhenExpressionError<Msg> on failure. |
WhenExpressionError<Msg> |
Branded error string. Assignability failure surfaces Msg in the TS tooltip. |
ContextPaths<T> |
Union of valid flat + ./: nested paths for T. |
MIT License © Anthony Fu