DEV Community

Mykola Melnyk
Mykola Melnyk

Posted on

onError must return the right shape, or the crash just moves

onError must return the right shape

Written 2026-09-05, about @stabrise/scaledp@0.1.1. Follows on from the non-throwing error contract; assumes TypeScript.

TL;DR: A pipeline where stages never throw needs each failed stage to still produce a value. Return null and you have not removed the crash — you have moved it three stages downstream, to a place with no idea what went wrong. So onError returns a well-formed empty instance of the stage's output schema, every schema ships a create* factory to make that trivial, and the payoff is that no downstream stage needs an error branch at all.

The half of the contract nobody designs

The interesting half of "stages never throw" is easy to state: catch the exception, record the message. Most people get there.

The half that decides whether the design works is: what value goes in the output column?

There is an obvious answer, and it is wrong.

The wrong answer

// Tempting. Do not do this.
protected onError(message: string): DetectorOutput | null {
  this.lastError = message
  return null
}
Enter fullscreen mode Exit fullscreen mode

It reads as honest — the stage produced nothing, so it returns nothing. What happens next:

// Three stages later, in a recognizer that knows nothing about the detector
const boxes = row.boxes as DetectorOutput
for (const box of boxes.bboxes) {    // TypeError: Cannot read properties of null
Enter fullscreen mode Exit fullscreen mode

You have converted one caught, attributed, per-row failure into an uncaught TypeError in a different stage. Worse, that TypeError is caught by that stage's error handling, so the message the user finally sees is:

PaddleRecognizer: TypeError: Cannot read properties of null (reading 'bboxes')
Enter fullscreen mode Exit fullscreen mode

The detector's actual failure — a model that would not load, a 404 on the weights — is gone. The reported stage is not the failed stage. This is precisely the outcome the contract exists to prevent, reintroduced by its own error handler.

undefined is the same bug with a different message. So is throwing from onError, which takes down the runner's catch block.

Returning null from onError turns one attributed failure into an unattributed TypeError three stages later

The right answer

protected onError(message: string): DetectorOutput {
  return createDetectorOutput({ exception: message })
}
Enter fullscreen mode Exit fullscreen mode

createDetectorOutput is the factory that ships with the schema:

export function createDetectorOutput(init: Partial<DetectorOutput> = {}): DetectorOutput {
  return {
    path: init.path ?? 'memory',
    type: init.type ?? 'detector',
    bboxes: init.bboxes ?? [],
    exception: init.exception ?? '',
  }
}
Enter fullscreen mode Exit fullscreen mode

Correct fields, empty values, one non-empty string. Every schema has one — createImage, createDocument, createDetectorOutput, createNerOutput — and they exist so that "an empty but valid instance of this schema" is never something a stage author has to get right by hand.

The base class makes the requirement unavoidable by typing it as abstract:

/**
 * Value written to `outputCol` when `apply` throws. Subclasses return an
 * empty instance of their output schema carrying the message, so downstream
 * stages see a well-formed value rather than `undefined`.
 */
protected abstract onError(message: string, row: Row): unknown
Enter fullscreen mode Exit fullscreen mode

You cannot write a stage without answering the question.

What this buys downstream

Here is the payoff, and it is bigger than it looks. Consider a recognizer consuming a detector's boxes:

protected async apply(input: unknown, row: Row): Promise<Document> {
  const image = row[this.params.inputCols[0]] as ScaleDpImage
  const boxes = row[this.params.inputCols[1]] as DetectorOutput

  // Check exception first -- a failed upstream stage returns a well-formed
  // but empty value, so testing the payload first buries the real cause.
  if (image.exception) return this.onError(image.exception, row)
  if (boxes.exception) return this.onError(boxes.exception, row)

  for (const box of boxes.bboxes) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Note what is not there. No null check. No ?.. No if (!boxes) return. The bboxes array is guaranteed to exist because the schema guarantees it, so the loop is over an empty array and does nothing.

Multiply that across eighteen stages and every display helper. The invariant is worth more than any individual null check, because null checks are things you forget and invariants are things you establish once.

A well-formed empty value means the downstream stage needs no null check and no error branch

The two exception lines are doing something different from a null check: they are propagating attribution. Without them the recognizer would happily return an empty document with an empty exception, and a genuinely failed pipeline would look like a blank page.

Empty is not the same as failed

A deliberate consequence, worth being explicit about because it is a design decision rather than an accident.

After a failed detector and after a detector that found nothing, boxes.bboxes.length === 0. Downstream code cannot distinguish them, and that is intentional — it is exactly why downstream code needs no error branch.

The distinction lives in one place, and one place only:

if (row.boxes.exception) {
  // failed
} else if (row.boxes.bboxes.length === 0) {
  // genuinely nothing on this page
}
Enter fullscreen mode Exit fullscreen mode

Some stages lean on this deliberately. TesseractScriptDetector returns an empty script with an empty exception when it cannot identify a script, because a blank page is a legitimate answer rather than a failure. Encoding "I looked and there was nothing" as an error would make blank pages noisy for no benefit.

Here is what that looks like when it actually happens. A PaddleRecognizer pointed at a column no stage writes, run in the builder:

The text column, flagged with a red dot, holding the recognizer's error message and stack rather than a null

The recognizer failed, and the result is still a Document. It has a text (empty), it has bboxes (empty), and it has an exception naming the stage and the cause. Nothing downstream of it has to know that anything went wrong — which is the entire argument for returning a shape instead of a null.

The display helpers cooperate

The invariant extends past the pipeline. Every display helper checks exception first:

export function showText(document_: Document, options: ShowTextOptions = {}): HTMLElement {
  if (document_.exception) return errorBlock(document_.exception)
  // ...
}
Enter fullscreen mode Exit fullscreen mode

So a failed stage renders as a red error block, not an empty panel. That matters more than it sounds: the failure mode of a quiet error contract is that failures become invisible, and an empty <pre> looks exactly like a page with no text on it. Making the UI layer aware of the same field is what keeps a silent contract from producing a silent product.

The rule, generalised

This is not really about OCR. Any pipeline that converts exceptions into values faces the same question, and the same answer applies:

If a failure produces a value, that value must satisfy every invariant a success would have satisfied. Otherwise you have not handled the error; you have deferred it to code that cannot attribute it.

Practically, that means three things:

  1. Give every result type a factory that produces a valid empty instance. If constructing "empty but valid" is fiddly, it will be got wrong.
  2. Put the failure marker inside the type, not beside it. A Result<T, E> wrapper also works, and is arguably cleaner — but it does not survive postMessage as nicely, and it forces every consumer to unwrap, which is exactly the per-consumer branch this design is avoiding.
  3. Make the error path type-checked, not documented. An abstract onError returning the schema type is enforced by the compiler. A comment saying "return an empty instance" is not.

Trade-offs

  • Result<T, E> is the better-typed alternative and we did not choose it. It makes the failure impossible to ignore, which is a genuine advantage over a string field you can forget to read. We took the schema-with-exception route for Python parity — the field exists in ScaleDP and has the same meaning — and because it clones across a worker boundary for free. That is a defensible trade, not an obviously correct one.
  • A well-formed empty value is easy to ignore. It is the same criticism as the contract itself: an empty Document flows into your index without complaint.
  • Factories add a small amount of ceremony. Every schema needs one, and a new field means updating it. In exchange, no stage ever hand-rolls an empty instance and gets a field wrong.
  • onError receives a string, not the original error. You lose the ability to branch on the error type inside onError. In practice the stage that threw already knows what it threw; the ones that do not are just passing an upstream message through.

Try it

npm install @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
Enter fullscreen mode Exit fullscreen mode

Open a pipeline in the builder and break a stage on purpose — point its inputCols at a column no stage writes. What you get back is a well-formed empty Document with a populated exception, which is exactly what this post is about.

Further reading

Top comments (0)