Skip to main content

13 · Error handling for hosts

Scenario

Your app calls convert(templateBuffer, dataBuffer). The template has a typo, or the data is missing a required column. Now what?

xl3 raises structured errors (per ADR-0015) with a stable error.code string. Hosts dispatch on the code — for localization, retry logic, or operator-friendly messages.

Catch + dispatch

import { convert, isXtlError } from '@xl3-lang/xl3';

try {
const outputs = await convert(templateBuffer, dataBuffer, options);
// ship outputs
} catch (err) {
if (isXtlError(err)) {
switch (err.code) {
case 'xl3/source/missing-header':
return showOperator('Your data file is missing required columns.', err.message);
case 'xl3/inputs/missing-required':
return promptForMissingInput(err.message);
case 'xl3/filename/collision':
return showOperator('Two output files would have the same name. Check your data.', err.message);
default:
return showOperator('Conversion failed.', err.message);
}
}
// Non-xl3 error: probably a system fault. Re-throw.
throw err;
}

isXtlError(value) returns true only for Error instances whose code starts with xl3/. Plain Error or DOMException etc. won't match.

The error code catalog

Stable. Append-only. Renames are breaking changes per ADR-0015. The current set:

  • xl3/cell/* — cell-level failures (formula-no-cache, numfmt-coercion, row-outside-repeat)
  • xl3/eval/* — expression evaluation (arity-mismatch, operand-coercion, unsupported-syntax)
  • xl3/config/*__config__ issues
  • xl3/inputs/* — runtime input failures
  • xl3/source/* — source data issues (missing headers, undeclared sources, reserved column names)
  • xl3/sources/*__sources__ sheet issues
  • xl3/sheet/* — sheet-name issues
  • xl3/directive/* — directive syntax
  • xl3/join/*@join clause issues
  • xl3/xlookup/*XLOOKUP failures
  • xl3/filename/* — output filename issues
  • xl3/parser/* — parser failures
  • xl3/lists/*__lists__ reference issues
  • xl3/expression/* — expression-level rejections (bracket-outside-block, unknown-name)
  • xl3/block/* — data-block layout (overlap, empty-table)
  • xl3/group/*@group issues (missing-key)
  • xl3/subtotal/*@subtotal issues (outside-group, bad-aggregate, mixed-row, explicit-block-unsupported)
  • xl3/source-json/* — JSON source input (invalid) — reachable via convertJson, previewJson, and validateSourceJson

Full list in impl/js/src/error-codes.ts.

Common cases worth handling explicitly

Missing required input (xl3/inputs/missing-required): The template declares an input as required: true and the host didn't supply it. Show a form, ask the operator, retry.

Filename collision (xl3/filename/collision): Two distinct file group keys sanitized to the same filename (e.g., Seoul/Korea and Seoul:Korea both → Seoul_Korea.xlsx). The operator usually needs to clean their data, not the template.

Source mismatch in XLOOKUP (xl3/xlookup/source-mismatch): The template author wrote XLOOKUP(x, A[k], B[v]) where A and B are different sources. Template needs fixing — not an operator issue.

No match in XLOOKUP (xl3/xlookup/no-match): The lookup value isn't in the lookup column. Either the operator's data is incomplete, or the template should use @join instead (drops unmatched rows).

Locale

error.message is English. To localize, dispatch on error.code in your host and provide your own messages — do NOT translate the engine's English strings. The English text is part of the conformance contract (fixtures match on substrings of it).

Validate before convert

For a compatibility gate, call validateSource(template, data) or validateSourceJson(template, sourceJson) first. Validation returns every source/schema problem it can name, instead of throwing on the first one, and it does not render workbooks.

import { validateSource } from '@xl3-lang/xl3';

const report = await validateSource(templateBuffer, dataBuffer);
if (!report.ok) {
return showOperator(
'Your data file does not match this template.',
report.diagnostics,
);
}

The default { depth: 'schema' } is the cheap preflight: declarations and headers only. Use { depth: 'full' } before a production conversion when row values may be malformed. Full depth also catches XLSX formula cells without a cached result and JSON row-width/tagged-value/date errors, while still avoiding rendering:

const report = await validateSourceJson(templateBuffer, sourceJson, {
depth: 'full',
});

validateSource*() is stricter than the legacy preview warning path: if the template reads {{ [Amount] }} and the source has no Amount header, the validator reports xl3/source/unknown-column as an error. That makes report.ok === true mean the template's required source columns are present.

The same gate is available without embedding JavaScript:

xl3 validate template.xlsx --data=data.xlsx
xl3 validate template.xlsx --data=source.json --json
xl3 validate template.xlsx --data=source.json --depth=full --json
cat source.json | xl3 validate template.xlsx --data=- --json

The command exits 0 when the source is compatible, 1 when validation reports errors, and 2 for invalid CLI usage. With --json, the complete ValidationReport is written to stdout even when the exit code is 1, so CI and operator tooling can display every diagnostic from a single run.

After validation, use preview(template, data, options) when the host needs planned filenames, sheet names, row counts, resolved inputs, or non-fatal warnings:

const preview = await xl3.preview(templateBuffer, dataBuffer, options);
// preview.warnings: non-fatal issues
// preview.inputs: resolved input values (after defaults + coercion)
// preview.files / preview.sources: what convert() would produce

preview() keeps xl3w/parser/missing-column warnings for backwards compatibility. New "Validate" buttons should prefer validateSource*().

Spec pointers

  • ADR-0015 — Structured error reporting.
  • ADR-0078 — Source compatibility validation API.
  • spec/evaluation.md "Errors".
  • Cookbook 06 for input-related errors.