@krllc/table-formulas
v0.10.0
Published
A cell-formula engine for table data. Formulas reference columns **by name** and evaluate **per row** — there is no cross-row addressing. Think of it as a post-processing stage: your rows arrive with raw data, and the formula engine fills in computed colu
Readme
@krllc/table-formulas
A cell-formula engine for table data. Formulas reference columns by name and evaluate per row — there is no cross-row addressing. Think of it as a post-processing stage: your rows arrive with raw data, and the formula engine fills in computed columns.
Install
npm install @krllc/table-formulasQuick start
import { compile } from '@krllc/table-formulas';
const processor = compile({
columns: [
{ name: 'subtotal', formula: 'price * quantity' },
{ name: 'tax', formula: 'subtotal * taxRate' },
{ name: 'total', formula: 'subtotal + tax' },
],
get: (row, col) => row[col],
set: (row, col, value, referencedColumns) => {
row[col] = value;
},
});
const row = { price: 29.99, quantity: 3, taxRate: 0.08 };
processor.process(row);
// row.subtotal → 89.97
// row.tax → 7.1976
// row.total → 97.1676compile() parses all formulas once, detects circular references, resolves
dependencies, and returns a lightweight processor you can call on every row.
Column references
Columns are referenced by name, not by letter like Excel's A, B, C.
| Column name | How to reference it |
|----------------|---------------------------|
| price | price |
| quantity | quantity |
| First Name | [First Name] |
| Tax Rate (%) | [Tax Rate (%)] |
Simple alphanumeric names (letters, digits, underscores) are bare identifiers. Names containing spaces or special characters are wrapped in square brackets.
Referencing other formula columns
Formula columns can reference each other. The engine topologically sorts them so dependencies always evaluate first, regardless of the order you list them:
const processor = compile({
columns: [
// Listed "backwards" — doesn't matter, engine sorts them
{ name: 'total', formula: 'subtotal + tax' },
{ name: 'tax', formula: 'subtotal * 0.1' },
{ name: 'subtotal', formula: 'price * quantity' },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
const row = { price: 50, quantity: 4 };
processor.process(row);
// Evaluates in order: subtotal (200) → tax (20) → total (220)No cross-row references
There is no A1 or ROW(3) syntax. Every formula only sees columns from the
current row. This makes the engine safe to parallelise across rows with no
shared state.
Formula syntax
Literals
| Type | Examples |
|---------|------------------------------|
| Number | 42, 3.14, 0.5 |
| String | "hello", 'world' |
| Boolean | TRUE, FALSE |
Strings support backslash escapes (\n, \t, \\, \", \') and
Excel-style doubled quotes ("She said ""hi""" → She said "hi").
Operators
Listed from lowest to highest precedence:
| Precedence | Operators | Description |
|------------|-----------------------|---------------------------------|
| 1 | = != <> < > <= >= | Comparisons (non-chaining) |
| 2 | + - & | Add, subtract, string concat |
| 3 | * / % | Multiply, divide, modulo |
| 4 | ^ | Power (right-associative) |
| 5 | - (unary) | Negation |
Parentheses override precedence: (a + b) * c.
String concatenation
Two equivalent ways:
first & " " & last
CONCAT(first, " ", last)Both produce "John Doe" when first is "John" and last is "Doe".
Template literals
For string-heavy formulas, backtick template literals are often cleaner than
& or CONCAT(...). Interpolations use plain {expr} — there is no $
prefix.
`Hello {firstName}, you have {count} items`Interpolated expressions can be any formula expression: columns, function calls, arithmetic, nested templates.
| Instead of | Write |
|-------------------------------------------------|-----------------------------------------|
| CONCAT("Hi, ", name, "!") | `Hi, {name}!` |
| [First Name] & " " & [Last Name] | `{[First Name]} {[Last Name]}` |
| "Margin: " & ROUND(margin * 100, 1) & "%" | `Margin: {ROUND(margin * 100, 1)}%` |
Values inside interpolations are coerced the same way as the & operator:
numbers to their string form, booleans to "TRUE"/"FALSE", null /
undefined to the empty string.
Escape sequences
| Sequence | Meaning |
|----------|------------------|
| \` | literal backtick |
| \{ | literal { |
| \\ | literal \ |
| \n | newline |
| \t | tab |
} in template text is a literal } — no escape needed.
Comparisons
status = "active"
price > 100
quantity != 0
score >= passingGradeComparisons return true or false and are typically used inside IF().
Built-in functions
Logical
| Function | Description |
|--------------------------------------|-----------------------------------------|
| IF(condition, trueValue, falseValue) | Returns trueValue or falseValue based on condition. Short-circuits — only the taken branch is evaluated. |
| AND(a, b, ...) | Returns true if all arguments are truthy. Short-circuits on first false. |
| OR(a, b, ...) | Returns true if any argument is truthy. Short-circuits on first true. |
| NOT(value) | Returns the boolean inverse. |
| IFERROR(expression, fallback) | Evaluates expression; if it throws (division by zero, bad reference, coercion failure), returns fallback instead. |
Examples
IF(quantity > 0, price * quantity, 0)
IF(AND(age >= 18, hasConsent = TRUE), "eligible", "ineligible")
IF(OR(status = "active", status = "trial"), "show", "hide")
IFERROR(revenue / costs, 0)
NOT(isArchived)Nested conditionals:
IF(score >= 90, "A",
IF(score >= 80, "B",
IF(score >= 70, "C", "F")))Math
| Function | Description |
|--------------------------|--------------------------------------------------|
| ROUND(number, decimals) | Round to N decimal places. |
| FLOOR(number) | Round down to nearest integer. |
| CEIL(number) | Round up to nearest integer. |
| ABS(number) | Absolute value. |
| MIN(a, b, ...) | Smallest of the arguments. |
| MAX(a, b, ...) | Largest of the arguments. |
| MOD(number, divisor) | Remainder after division. |
| POWER(base, exponent) | base raised to exponent (same as ^). |
| SQRT(number) | Square root. |
Examples
ROUND(price * taxRate, 2) → 7.20
FLOOR(rating) → 4
CEIL(shipping / weight) → 3
ABS(balance) → 150.00
MIN(price, competitorPrice) → 9.99
MAX(score1, score2, score3) → 95
MOD(rowIndex, 2) → 0 or 1 (for zebra striping)
POWER(2, bits) → 256
SQRT(area) → 12String
| Function | Description |
|-----------------------------------|---------------------------------------------------|
| CONCAT(a, b, ...) | Join all arguments into one string. |
| LEFT(text, count) | First count characters. |
| RIGHT(text, count) | Last count characters. |
| MID(text, start, count) | Substring starting at start (1-based, like Excel). |
| LEN(text) | Character count. |
| TRIM(text) | Strip leading/trailing whitespace. |
| UPPER(text) | Convert to uppercase. |
| LOWER(text) | Convert to lowercase. |
| SUBSTITUTE(text, old, new) | Replace all occurrences of old with new. |
Examples
CONCAT(firstName, " ", lastName) → "Jane Doe"
LEFT(zipCode, 3) → "902"
RIGHT(phone, 4) → "5678"
MID(ssn, 5, 2) → "56"
LEN(description) → 42
TRIM(userInput) → "hello"
UPPER(country) → "US"
LOWER(email) → "[email protected]"
SUBSTITUTE(slug, " ", "-") → "my-blog-post"URL
| Function | Description |
|----------------------|---------------------------------------------------|
| URLENCODE(text) | Percent-encodes a value for use in URLs. |
| URLDECODE(text) | Decodes a percent-encoded string. |
Examples
CONCAT("https://example.com/search?q=", URLENCODE(query))
→ "https://example.com/search?q=hello%20world"
URLDECODE(rawParam)
→ "price>=100"Regex
Microsoft-compatible regex builtins. All patterns are compiled with the
JavaScript u (Unicode) flag — Unicode-aware matching, surrogate-pair
correctness, and \p{...} property escapes. Invalid patterns throw
FUNCTION_ERROR so they can be flagged per cell via onRuntimeError.
| Function | Description |
|----------|-------------|
| REGEXREPLACE(text, pattern, replacement, [occurrence=0], [case_sensitivity=0]) | Replace matches. occurrence: 0 = all, N = the Nth match (1-indexed). case_sensitivity: 0 = sensitive, 1 = insensitive. |
| REGEXTEST(text, pattern, [case_sensitivity=0]) | true if the pattern matches anywhere in text. |
| REGEXEXTRACT(text, pattern, [return_mode=0], [case_sensitivity=0]) | First match (mode 0) as a string. Modes 1 and 2 (array-returning) aren't supported yet. |
Examples
REGEXREPLACE(name, "\s+", "_") → "John_Doe" (collapse whitespace)
REGEXREPLACE("abc 123", "(\d+)", "<$1>") → "abc <123>" ($1 backref)
REGEXREPLACE("a.b.c", "\\.", "X") → "aXbXc" (literal dot)
REGEXTEST(email, "@\w+\.\w+") → true / false
REGEXEXTRACT(url, "https://([^/]+)") → "example.com"See the Regex functions section in USERGUIDE.md for the backslash-escape table and pattern-authoring tips.
Type / utility
| Function | Description |
|----------------------|---------------------------------------------------|
| ISNUMBER(value) | true if value is a number or numeric string. |
| ISBLANK(value) | true if value is null, undefined, or "". |
| VALUE(text) | Parse a number from a string. Throws if not numeric. |
| TEXT(value) | Convert any value to its string representation. |
| COALESCE(a, b, ...) | First argument that is not null/undefined. |
Examples
IF(ISBLANK(middleName), CONCAT(first, " ", last), CONCAT(first, " ", middleName, " ", last))
IF(ISNUMBER(input), input * 2, 0)
VALUE("123.45") + 1 → 124.45
TEXT(total) & " USD" → "220 USD"
COALESCE(nickname, firstName, "Anonymous") → uses first non-nullType coercion
The engine uses loose coercion with Number() (not parseInt/parseFloat):
| From | To number | To boolean | To string |
|-------------|-----------|-----------------|------------|
| number | as-is | 0 → false | String() |
| string | Number()| non-empty → true, "TRUE"/"FALSE" honoured | as-is |
| boolean | true → 1, false → 0 | as-is | "TRUE" / "FALSE" |
| null/undefined | 0 | false | "" |
Arithmetic operators (+, -, *, /, %, ^) coerce both sides to
numbers. The & operator coerces both sides to strings. Comparisons use smart
matching — if both sides are numeric, compare numerically; if both are strings,
compare lexicographically; mixed types attempt numeric then fall back to string.
If coercion fails (e.g. Number("abc") is NaN), the engine raises a
TYPE_ERROR with warning severity.
API reference
compile<T>(options): CompiledProcessor<T>
| Option | Type | Description |
|-------------|------|-------------|
| columns | FormulaColumn[] | Array of { name, formula } objects. |
| get | (row: T, columnName: string) => unknown | Retrieve a column value from a row. |
| set | (row: T, columnName: string, value: unknown, referencedColumns: string[]) => void | Called once per formula column after successful evaluation. referencedColumns lists every column the formula reads. |
| onError | (error: FormulaError, row?: T) => unknown | (Optional) Error handler. Return a value to use it as the cell result; return undefined to skip the column. row is undefined for compile-time errors. |
| functions | Record<string, Function> | (Optional) Custom functions to register (case-insensitive). |
| requireTemplateVars | boolean | (Optional, default false) When true, every template interpolation is treated as if wrapped in REQUIRE() — a blank value anywhere in a template bails the whole formula. Wrap a single interp in OPTIONAL(x) to opt out. |
CompiledProcessor<T>
| Method | Description |
|----------------|-------------|
| process(row) | Evaluate all formula columns for the given row. |
Inspecting formulas
For lightweight introspection without spinning up a full compile:
import { getReferencedColumns, extractColumnRefs, parse } from '@krllc/table-formulas';
getReferencedColumns('ROUND(price * (1 + taxRate), 2)')
// → ['price', 'taxRate']
// Or, when you already have a parsed AST:
const ast = parse(formula);
extractColumnRefs(ast)
// → ['price', 'taxRate']| Function | Description |
|---|---|
| getReferencedColumns(formula) | Parses and returns the de-duplicated list of referenced column names. Throws FormulaParseError on invalid syntax. |
| extractColumnRefs(ast) | Lower-level AST walker; same return shape. Use when you already have an AST in hand. |
| renameReferencedColumns(formula, mapping) | Rewrites column references using { oldName: newName }. Returns a new formula string. Preserves whitespace verbatim. |
Both extraction helpers include bracket identifiers ([First Name]) and refs inside template interpolations (`hello {firstName}`).
Renaming column references
When a column gets renamed in your app, rewrite every formula that references it:
import { renameReferencedColumns } from '@krllc/table-formulas';
renameReferencedColumns('ROUND(price * (1 + taxRate), 2)', {
price: 'cost',
taxRate: 'rate',
})
// → 'ROUND(cost * (1 + rate), 2)'
renameReferencedColumns('[First Name] & " " & [Last Name]', {
'First Name': 'firstName',
})
// → 'firstName & " " & [Last Name]'
renameReferencedColumns('price', { price: 'Unit Price' })
// → '[Unit Price]' — bracket form when the new name isn't bare-safe- Function names are never renamed —
ROUND(price, 2)with{ ROUND: 'X' }is unchanged. - Single pass over the original AST —
a + bwith{ a: 'b', b: 'c' }becomesb + c, notc + c. - Bracket form is auto-emitted when the new name contains spaces / special chars, starts with a digit, or is
TRUE/FALSE. - Throws if a new name is empty or contains
](the bracket form has no escape for the closing bracket). - Throws
FormulaParseErroron invalid input formula.
FormulaError
Every error carries full context:
interface FormulaError {
code: FormulaErrorCode; // What went wrong
severity: FormulaErrorSeverity; // How bad is it
column: string; // Which formula column
formula: string; // The formula text
referencedColumns: string[]; // Columns the formula depends on
message: string; // Human-readable description
cause?: unknown; // Original thrown error, if any
}Error codes and severities
| Code | Severity | When |
|---------------------|-----------|---------------------------------------------------|
| CIRCULAR_REFERENCE| fatal | Compile-time: formula columns form a cycle. |
| PARSE_ERROR | fatal | Compile-time: formula syntax is invalid. |
| REFERENCE_ERROR | error | Runtime: get() threw when reading a column. |
| TYPE_ERROR | warning | Runtime: value could not be coerced (e.g. Number("abc")). |
| EVAL_ERROR | error | Runtime: evaluation failure (e.g. division by zero). |
| FUNCTION_ERROR | error | Runtime: function not found or threw. |
| DEPENDENCY_ERROR | error | Runtime: a formula tried to read a column that errored on the same row. cause carries the originating error. |
Error handling
Two phases, two callbacks
Errors split into two phases — compile and runtime — and each has its own optional callback:
onCompileError(error)— fires once perPARSE_ERROR/CIRCULAR_REFERENCEduringcompile(). Return value is ignored (no row in scope).onRuntimeError(error, row)— fires per (row, column) duringprocess(row). Optional return value is used as the cell's value (and cached for downstream formulas); returningundefinedleaves the column unset and marks it errored on the row, which cascades to dependents.
const processor = compile({
columns: [
{ name: 'ratio', formula: 'revenue / costs' },
{ name: 'label', formula: 'IF(ratio > 1, "profit", "loss")' },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
onCompileError: (error) => {
console.error(`[compile] ${error.column}: ${error.message}`);
},
onRuntimeError: (error, row) => {
if (error.code === 'EVAL_ERROR') return 0; // division by zero → 0
if (error.code === 'TYPE_ERROR') return null; // coercion failure → null
return undefined; // skip this column; dependents cascade
},
});Defaults when no callback is supplied
- Compile error:
compile()throws on the firstPARSE_ERROR/CIRCULAR_REFERENCE— unlesstolerateCompileErrors: trueis set (see below). - Runtime error: the column is silently skipped. Dependents cascade (see "Cascade-on-error" below).
Legacy onError (deprecated, still works)
A single onError(error, row?) callback is supported as a unified fallback
for both phases. It fires whenever the phase-specific callback isn't set.
New code should prefer onCompileError + onRuntimeError.
tolerateCompileErrors — keep going through compile failures
const processor = compile({
columns: [...],
get, set,
tolerateCompileErrors: true,
onRuntimeError: (error, row) => {
// Now fires for parse / circular errors too — once per (row, column).
return undefined;
},
});When true, compile() does not throw on PARSE_ERROR /
CIRCULAR_REFERENCE. The errored columns are excluded from the eval order,
but each process(row) call replays the compile error through
onRuntimeError (or onError) once for each affected column. This lets a
grid render with the rest of the rows intact and decorate the affected
cells with an error indicator.
The compileErrors array is exposed on the returned processor regardless
of mode, so you can inspect compile-time issues without registering a
callback:
const processor = compile({ ..., tolerateCompileErrors: true });
if (processor.compileErrors.length > 0) {
console.warn(processor.compileErrors);
}Cascade-on-error vs bail-no-cascade
When a column errors at runtime and no fallback is returned, the runner
marks the column errored on that row. Any downstream formula that reads
the errored column fails with a DEPENDENCY_ERROR (with cause linking
back to the originating error), so the error surfaces as a per-cell
indicator rather than silently fanning out as bad numeric values.
Bails are different. BAIL() and REQUIRE(blank) produce null for the
column, not an error. Dependents read that null and continue normally —
typical use:
totals: BAIL() // → null
label: COALESCE(totals, "n/a") // → "n/a", no errorReturning a fallback from onRuntimeError also short-circuits the
cascade — dependents see the fallback, not an error.
IFERROR — formula-level error handling
IFERROR catches errors inside a formula without reaching the runtime
handler:
IFERROR(revenue / costs, 0)If costs is zero, the division throws, IFERROR catches it, the formula
returns 0, and neither onRuntimeError nor onError is called. Useful
for expected edge cases you want to handle inline.
Custom functions
Register functions at compile time. Names are case-insensitive.
const processor = compile({
columns: [
{ name: 'slug', formula: 'SLUGIFY(title)' },
{ name: 'initials', formula: 'INITIALS(firstName, lastName)' },
{ name: 'age', formula: 'YEARS_SINCE(birthDate)' },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
functions: {
SLUGIFY: (text: unknown) =>
String(text).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
INITIALS: (first: unknown, last: unknown) =>
`${String(first)[0]}${String(last)[0]}`.toUpperCase(),
YEARS_SINCE: (dateStr: unknown) => {
const d = new Date(String(dateStr));
return Math.floor((Date.now() - d.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
},
},
});Custom functions can override built-ins:
functions: {
// Override ROUND to always use banker's rounding
ROUND: (n: unknown, d: unknown) => {
const factor = 10 ** Number(d);
const shifted = Number(n) * factor;
const rounded = Math.round(shifted + (shifted % 1 === 0.5 ? -1 : 0));
return rounded / factor;
},
}Reserved names:
IF,IFERROR,AND,ORare handled as special forms with short-circuit evaluation and cannot be overridden by custom functions.
Circular reference detection
The engine builds a dependency graph across formula columns and topologically
sorts them. If a cycle exists, every column in the cycle is excluded from
processing and reported via onError (or thrown if no handler).
// This will report a CIRCULAR_REFERENCE error
const processor = compile({
columns: [
{ name: 'a', formula: 'b + 1' },
{ name: 'b', formula: 'a + 1' },
{ name: 'c', formula: 'val * 2' }, // not in the cycle
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
onError: (error) => {
if (error.code === 'CIRCULAR_REFERENCE') {
console.error(error.message); // "Circular reference detected: a → b → a"
}
return undefined;
},
});
const row = { val: 5 };
processor.process(row);
// row.c → 10 (unaffected columns still process)
// row.a → undefined (circular, skipped)
// row.b → undefined (circular, skipped)Self-references are also caught: { name: 'x', formula: 'x + 1' }.
Realistic examples
E-commerce line items
const processor = compile({
columns: [
{ name: 'subtotal', formula: 'price * quantity' },
{ name: 'discount', formula: 'IF(quantity >= 10, subtotal * 0.1, 0)' },
{ name: 'taxable', formula: 'subtotal - discount' },
{ name: 'tax', formula: 'ROUND(taxable * taxRate, 2)' },
{ name: 'total', formula: 'taxable + tax' },
{ name: 'perUnit', formula: 'ROUND(total / quantity, 2)' },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
processor.process({ price: 29.99, quantity: 12, taxRate: 0.08 });
// subtotal → 359.88
// discount → 35.988
// taxable → 323.892
// tax → 25.91
// total → 349.802
// perUnit → 29.15User display names
const processor = compile({
columns: [
{
name: 'displayName',
formula: `
IF(ISBLANK(nickname),
IF(ISBLANK([Middle Name]),
CONCAT([First Name], " ", [Last Name]),
CONCAT([First Name], " ", LEFT([Middle Name], 1), ". ", [Last Name])),
nickname)
`,
},
{
name: 'initials',
formula: 'UPPER(CONCAT(LEFT([First Name], 1), LEFT([Last Name], 1)))',
},
{
name: 'email',
formula: 'LOWER(CONCAT([First Name], ".", [Last Name], "@example.com"))',
},
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
processor.process({
'First Name': 'Jane',
'Middle Name': 'Elizabeth',
'Last Name': 'Doe',
nickname: null,
});
// displayName → "Jane E. Doe"
// initials → "JD"
// email → "[email protected]"Building URLs from row data
const processor = compile({
columns: [
{
name: 'profileUrl',
formula: 'CONCAT("https://app.example.com/users/", URLENCODE(userId))',
},
{
name: 'searchUrl',
formula: `CONCAT(
"https://api.example.com/search?name=", URLENCODE(name),
"&city=", URLENCODE(city))`,
},
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
processor.process({ userId: 'usr 123', name: 'Jane Doe', city: 'San Francisco' });
// profileUrl → "https://app.example.com/users/usr%20123"
// searchUrl → "https://api.example.com/search?name=Jane%20Doe&city=San%20Francisco"Data quality scoring
const processor = compile({
columns: [
{
name: 'completeness',
formula: `
(IF(ISBLANK(email), 0, 1)
+ IF(ISBLANK(phone), 0, 1)
+ IF(ISBLANK(address), 0, 1)) / 3
`,
},
{
name: 'qualityLabel',
formula: `
IF(completeness >= 1, "complete",
IF(completeness >= 0.66, "good",
IF(completeness >= 0.33, "partial", "poor")))
`,
},
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
processor.process({ email: '[email protected]', phone: null, address: '123 Main St' });
// completeness → 0.666...
// qualityLabel → "good"Conditional formatting flags
const processor = compile({
columns: [
{ name: 'isOverdue', formula: 'AND(status != "paid", daysOpen > 30)' },
{ name: 'isHighValue', formula: 'amount > 10000' },
{ name: 'needsReview', formula: 'OR(isOverdue, isHighValue)' },
{ name: 'rowClass', formula: `
IF(isOverdue, "row-danger",
IF(isHighValue, "row-warning", "row-normal"))
` },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
});
processor.process({ status: 'pending', daysOpen: 45, amount: 500 });
// isOverdue → true
// isHighValue → false
// needsReview → true
// rowClass → "row-danger"Graceful error recovery
const processor = compile({
columns: [
{ name: 'margin', formula: 'IFERROR((revenue - cost) / revenue, 0)' },
{ name: 'marginPct', formula: 'CONCAT(ROUND(margin * 100, 1), "%")' },
{ name: 'safe', formula: 'COALESCE(region, country, "Unknown")' },
],
get: (row, col) => row[col],
set: (row, col, value) => { row[col] = value; },
onError: (error, row) => {
// Log all errors for monitoring
console.warn(
`[${error.severity}/${error.code}] Column "${error.column}" ` +
`(formula: ${error.formula}): ${error.message}`
);
// Fallback based on error type
if (error.severity === 'warning') return null;
return undefined;
},
});
processor.process({ revenue: 0, cost: 100, region: null, country: null });
// margin → 0 (IFERROR catches div-by-zero)
// marginPct → "0%"
// safe → "Unknown"FormulaEditor (React component)
A rich editor component for authoring formulas with syntax highlighting, real-time validation, and autocomplete. Requires React >=16.8.
import { FormulaEditor } from '@krllc/table-formulas';Basic usage
import { FormulaEditor } from '@krllc/table-formulas';
function App() {
return (
<FormulaEditor
columns={[
{ name: 'price', description: 'Unit price' },
{ name: 'quantity', description: 'Item count' },
{ name: 'First Name', label: 'First Name' },
]}
placeholder="Enter formula..."
onChange={(formula, info) => {
console.log('Formula:', formula);
console.log('AST:', info.ast);
console.log('Error:', info.error);
}}
/>
);
}Props
| Prop | Type | Description |
|----------------|------|-------------|
| value | string | Controlled value. |
| defaultValue | string | Initial value (uncontrolled). |
| onChange | (formula, info) => void | Called on every change with formula string, AST, error, and tokens. |
| columns | ColumnDef[] | Column definitions for autocomplete. |
| functions | FunctionDef[] | Function definitions. Defaults to all built-in functions. |
| colors | FormulaColorConfig | Color overrides (merged with DEFAULT_COLORS). |
| styles | FormulaStyleConfig | Layout style overrides. |
| placeholder | string | Placeholder text. |
| disabled | boolean | Disable all interaction. |
| readOnly | boolean | Allow selection but not editing. |
| className | string | CSS class on the outer container. |
| style | CSSProperties | Inline styles on the outer container. |
| onFocus | () => void | Focus callback. |
| onBlur | () => void | Blur callback. |
Controlled vs uncontrolled
// Uncontrolled — editor manages its own state
<FormulaEditor defaultValue="price * quantity" onChange={(f) => save(f)} />
// Controlled — you manage the value
const [formula, setFormula] = useState('price * quantity');
<FormulaEditor value={formula} onChange={(f) => setFormula(f)} />Imperative handle
const ref = useRef<FormulaEditorHandle>(null);
<FormulaEditor ref={ref} columns={columns} />
// Later:
ref.current.getValue(); // read formula
ref.current.setValue('a + b'); // set formula
ref.current.focus(); // focus editorTheming
The editor uses inline styles only — no CSS files. Override colors and
layout with the colors and styles props.
import { DARK_COLORS } from '@krllc/table-formulas';
<FormulaEditor
columns={columns}
colors={DARK_COLORS}
styles={{ editorBorderRadius: '0', editorPadding: '8px 12px' }}
/>Color presets: DEFAULT_COLORS (light, VS Code-inspired) and DARK_COLORS.
All color keys are optional — omitted keys fall back to the defaults.
Autocomplete
The dropdown appears automatically when you type a column or function name prefix. Keyboard navigation:
- Arrow Up/Down — navigate suggestions
- Enter / Tab — accept selected suggestion
- Escape — close dropdown
Column names with spaces are auto-wrapped in brackets. Function names
auto-append (.
Custom functions in autocomplete
Pass functions to show your custom functions alongside the built-ins:
<FormulaEditor
columns={columns}
functions={[
...BUILTIN_FUNCTIONS,
{ name: 'SLUGIFY', description: 'URL-safe slug', signature: 'SLUGIFY(text)' },
{ name: 'INITIALS', description: 'First letters', signature: 'INITIALS(first, last)' },
]}
/>FormulaChangeInfo
The second argument to onChange:
interface FormulaChangeInfo {
ast: ASTNode | null; // Parsed AST, or null on error
error: FormulaParseError | null; // Parse error with start/end positions
tokens: Token[]; // Fault-tolerant token array
}HighlightedContent — read-only syntax highlighting
A standalone React component that renders a formula as a syntax-highlighted
inline <span>. Useful for table cells, tooltips, documentation, or any
read-only display where the full editor would be overkill.
import { HighlightedContent, DARK_COLORS } from '@krllc/table-formulas';
function FormulaCell({ formula }: { formula: string }) {
return <HighlightedContent formula={formula} />;
}
// Dark mode, custom container style
<HighlightedContent
formula='ROUND(price * (1 + taxRate), 2)'
colors={DARK_COLORS}
style={{ fontFamily: 'monospace' }}
/>Props:
| Prop | Type | Description |
|---|---|---|
| formula | string | The formula source to highlight. |
| colors? | FormulaColorConfig | Partial overrides on the default palette. Pass DARK_COLORS for the dark theme. |
| className? | string | Class name on the rendered <span>. |
| style? | React.CSSProperties | Inline styles merged onto the <span>. whiteSpace: 'pre' is applied by default. |
The component tokenizes internally with the fault-tolerant tokenizer, so
invalid or partial input still renders — broken regions get a wavy
underline in the error color. For non-React contexts, use the underlying
buildHighlightedHTML(formula, tokens, colors) function (also exported).
Architecture
Everything is exported from a single package entry point. React is an optional
peer dependency — if you only use the core formula engine functions (compile,
parse, tokenize, etc.), you don't need React installed.
License
MIT
