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
}
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
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')
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.
The right answer
protected onError(message: string): DetectorOutput {
return createDetectorOutput({ exception: message })
}
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 ?? '',
}
}
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
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) {
// ...
}
}
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.
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
}
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 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)
// ...
}
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:
- Give every result type a factory that produces a valid empty instance. If constructing "empty but valid" is fiddly, it will be got wrong.
-
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 survivepostMessageas nicely, and it forces every consumer to unwrap, which is exactly the per-consumer branch this design is avoiding. -
Make the error path type-checked, not documented. An abstract
onErrorreturning 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-exceptionroute 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
Documentflows 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.
-
onErrorreceives a string, not the original error. You lose the ability to branch on the error type insideonError. 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
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
- The error contract
-
Schemas — every
create*factory - The stage lifecycle
- Display helpers — how failures render
- Repo: StabRise/scaledp-ts




Top comments (0)