Skip to content
Abdulkader Safi edited this page Jul 16, 2026 · 2 revisions

Errors

Every failure is an SImgError with a code. Never a raw TypeError from three frames deep inside a decoder, never a bare string, never undefined where an image should be.

import { SImgError, UnsupportedFormatError, ImageTooLargeError, CorruptImageError } from 'safi-image';

try {
  await SImg.fromBuffer(bytes).toFormat('webp').toBuffer();
} catch (e) {
  if (e instanceof UnsupportedFormatError) {
    // not an image we read
  } else if (e instanceof ImageTooLargeError) {
    // a decompression bomb, or a genuinely enormous photo
  } else if (e instanceof SImgError) {
    switch (e.code) { /* ... */ }
  }
}

They are classes rather than just codes because a consumer's catch block is the whole point: instanceof narrows, and the subclasses all narrow to SImgError.

The codes

Code Class Means
UNSUPPORTED_FORMAT UnsupportedFormatError The magic bytes matched nothing we read
CORRUPT_IMAGE CorruptImageError The magic bytes matched; the rest did not parse
FORMAT_MISMATCH SImgError The header says PNG and the caller said JPEG. We trust the header
INVALID_OPTION InvalidOptionError A crop outside the image, a resize to 0, a quality of 500
IMAGE_TOO_LARGE ImageTooLargeError Declared dimensions exceed the decode cap
CODEC_LOAD_FAILED SImgError A lazily-loaded codec (WebP) could not load
ENCODE_FAILED SImgError The encoder itself blew up

e.code is typed as SImgErrorCode, so a switch over it is exhaustive-checkable.

The messages are for whoever reads the bug report

Not for whoever wrote the throw:

jpeg.quality must be an integer from 1 to 100, got 500
pipeline.resize.maxLongEdge must be a number, got "banana"
Not a supported image format. First bytes: 00 01 02 03

They say what was wanted, what arrived, and — where there is a list — what the options are. UnsupportedFormatError quotes the first bytes, so a bug report tells you what the file actually was rather than what its extension claimed.

InvalidOptionError also carries .option and .value if you want to build your own message.

The ones you will actually hit

UNSUPPORTED_FORMAT on a real photo

Almost always a progressive JPEG. They are not supported yet, and they are common — this is a known gap, and it fails loudly on purpose rather than producing garbage.

IMAGE_TOO_LARGE

The decompression-bomb guard: a 100×100 PNG can declare itself 60,000×60,000 and cost you 14 GB. The cap is 134M pixels (~512 MB of RGBA), checked from the header, before allocating anything.

If you meant it, raise it per call:

await decode(bytes, { maxPixels: 500_000_000 });

INVALID_OPTION from a stored spec

A settings file someone hand-edited, or one written by an older version. SImg.pipeline(spec) validates at construction, so it throws while the caller still has the file open rather than three steps later inside a transform.

CODEC_LOAD_FAILED

WebP's WASM did not load. It is permanent: WebP moves to unavailable and is not retried. A codec that failed to load will fail again, and retrying once per image across a batch of 500 turns one error into 500 slow ones.

supportedFormats().unavailable; // ['webp'] — offer the user something else

Check supportedFormats() rather than catching this per file.

Not everything is an SImgError

fromFile on a missing path throws Node's ENOENT, unwrapped. Deliberate: a missing file is not an image error, and Node's message is better than anything written over the top of it.

Types are not validation

Every option is checked at runtime, even the ones the types cover. Two reasons, both real:

// 1. A settings file. No types anywhere near this.
const config = JSON.parse('{"quality": null}');

// 2. TypeScript's own holes. Excess property checking only fires on literals:
const options = { quality: 80 };
encode(img, 'png', options); // compiles. Throws at runtime, correctly.

The types are for the developer at 3pm. The validation is for production at 3am.

Clone this wiki locally