whenexpr

Tiny when-clause expression evaluator with full type-safty

30
0
30
TypeScript
public

whenexpr

npm version
npm downloads
bundle
JSDocs
License

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.

Install

pnpm add whenexpr

Usage

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"

Expression Syntax

whenexpr supports a JavaScript-expression subset: logical, equality, relational, and arithmetic operators, plus parentheses for grouping.

Literals

Kind Example
Boolean true, false
Number 42, 1.5
String "dev", 'dev'

Operators

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

Precedence (low to high)

||&& → 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
    

Examples

// 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

Namespaced Context Keys

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)

Lookup Order

When resolving a namespaced key like vite.mode:

  1. Exact match — looks for ctx['vite.mode'] first
  2. Nested path — falls back to ctx.vite?.mode

Flat keys take priority over nested objects if both exist.

Type Safety

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

Levels of checking

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

Building your own typed define helpers

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

API

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.

Strict mode

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 helpers

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.

Sponsors

Sponsors

License

MIT License © Anthony Fu

v0.3.3[beta]