Designing error types
Four choices, in rising order of cost. Throw Error with a message, which is correct more often than its reputation suggests, because if nobody will branch on the failure, a good message is the whole requirement. Throw a built-in subclass when one genuinely fits: TypeError for a value of the wrong shape, RangeError for one outside its bounds. Write your own subclass when callers need to tell your failures apart from each other and from everyone else's. Or return a value instead of throwing, which is not an error type at all, and the right answer whenever the failure is a normal outcome rather than a surprise.
One question picks between them: who reads this failure, and what do they need to decide? A human reading a log needs a message and a chain. Code that branches needs something it can compare. Something across a process boundary needs whatever survives the trip, which is less than you would hope.
Create programs/designing-error-types.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
what an Error carries
Deno.test("what an Error carries", () => {
assertStrictEquals(new Error().message, "");
assertStrictEquals(String(new Error("Hello!")), "Error: Hello!");
assertStrictEquals(new Error("x").name, "Error");
assertStrictEquals(new RangeError("x").name, "RangeError");
assertStrictEquals(Error.prototype.name, "Error");
});
Check programs/designing-error-types.test.ts
running 1 test from ./programs/designing-error-types.test.ts
what an Error carries ... ok (0ms)
ok | 1 passed | 0 failed (2ms)
.message is the text, and it defaults to the empty string rather than undefined. .name comes from the prototype, and the last assertion pins where it actually lives, which matters two steps from now. String(err) joins the two. There is also .cause, below, and .stack, which the errors and exceptions page measured in the stack.
an instance takes your own properties
Deno.test("an instance takes your own properties", () => {
const error = Object.assign(new Error("could not reach server"), {
server: "https://127.0.0.1",
});
assertStrictEquals(error.server, "https://127.0.0.1");
assertStrictEquals(error.message, "could not reach server");
assert(Deno.inspect(error).includes("server"));
});
an instance takes your own properties ... ok (0ms)
This is less limiting here than in a more static language, and Object.assign keeps it type-safe: the result is Error & { server: string }, so the property is known rather than asserted, which is why the read needs no cast. Reach for this when one call site needs one extra field and a whole class would be ceremony.
a subclass does not get its own name
Write the obvious subclass and predict what its instances call themselves:
Deno.test("a subclass does not get its own name", () => {
class Bare extends Error {}
assertStrictEquals(new Bare("boom").name, "Bare");
assertStrictEquals(String(new Bare("boom")), "Bare: boom");
});
Check programs/designing-error-types.test.ts
running 3 tests from ./programs/designing-error-types.test.ts
...
a subclass does not get its own name ... FAILED (8ms)
ERRORS
a subclass does not get its own name => ./programs/designing-error-types.test.ts:28:11
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- Error
+ Bare
FAILURES
a subclass does not get its own name => ./programs/designing-error-types.test.ts:28:11
FAILED | 2 passed | 1 failed (11ms)
error: Test failed
Bare inherits name from Error.prototype and does not replace it, so the instance reports "Error", String() gives "Error: boom", and the first line of its stack trace says Error as well. Every place a human would look, your error is anonymous. The name is not lost, just nowhere anything looks: constructor.name has it. The fix is to put it where things do look, and noImplicitOverride, the same TS4114 the subclassing page captured, insists you say you meant to:
Deno.test("a subclass does not get its own name", () => {
class Bare extends Error {}
assertStrictEquals(new Bare("boom").name, "Error");
assertStrictEquals(String(new Bare("boom")), "Error: boom");
assertStrictEquals(new Bare("boom").constructor.name, "Bare");
class Implicit extends Error {
// @ts-expect-error: This member must have an 'override' modifier because it overrides a member in the base class 'Error'.
name = "Implicit";
}
assertStrictEquals(new Implicit("boom").name, "Implicit");
class ConfigError extends Error {
override name = "ConfigError";
constructor(
message: string,
readonly path: string,
options?: ErrorOptions,
) {
super(message, options);
}
}
const error = new ConfigError("bad config", "/etc/app.json");
assertStrictEquals(error.name, "ConfigError");
assertStrictEquals(error.path, "/etc/app.json");
assertStrictEquals(String(error), "ConfigError: bad config");
});
a subclass does not get its own name ... ok (0ms)
ConfigError is the full shape, and three things earn their place. The override name is not optional decoration, as the failure just measured. The path is the context a caller might want. And options is forwarded to super, which is what keeps cause working for anyone constructing your error. Classes themselves are the classes page's subject; nothing here needs more of them than extends and super.
cause keeps both halves of the story
Deno.test("cause keeps both halves of the story", () => {
function loadConfig(path: string): string {
try {
return JSON.parse("not json") as string;
} catch (error) {
throw new Error(`while loading ${path}`, { cause: error });
}
}
const error = assertThrows(
() => loadConfig("/etc/app.json"),
Error,
"while loading /etc/app.json",
);
assert(error.cause instanceof SyntaxError);
assertStrictEquals(error.cause.name, "SyntaxError");
});
cause keeps both halves of the story ... ok (1ms)
The inner failure knew the JSON was malformed and had no idea which file it came from. This function knows the file and nothing about JSON. Chaining keeps both instead of making you pick, and it is the reason to catch and rethrow at a boundary, the move the errors page called catching is a decision, rather than letting the original travel alone.
the cause is invisible almost everywhere
Deno.test("the cause is invisible almost everywhere", () => {
const chained = new Error("outer", { cause: new TypeError("the cause") });
assertStrictEquals(String(chained), "Error: outer");
assertFalse((chained.stack ?? "").includes("the cause"));
assert(Deno.inspect(chained).includes("[cause]: TypeError: the cause"));
});
the cause is invisible almost everywhere ... ok (0ms)
String(err) hides it. .stack hides it. Only inspection shows it, under [cause], which is what the console uses, by a mechanism the console reference covers. So console.error(error) and console.error(error.message) are not variations on a theme: the second throws away every chain you carefully built, plus the stack. Log the error object. This is the practical payoff of the whole section, and it costs nothing.
One temptation to head off while the slot is in view: context data does not belong in cause. It accepts any value and the console prints it nicely, so { cause: { server } } looks like free structured logging, and it is wrong twice. It spends the one slot chaining needs, so you can have context or a chain but not both. And it says something untrue, because cause means "the error that caused this one", and a server URL did not cause anything. An own property or a subclass field carries the same data without the lie, and inspection prints those too.
telling a family of errors apart
Deno.test("telling a family of errors apart", () => {
class NotFound extends Error {
override name = "NotFound";
readonly code = "NOT_FOUND" as const;
}
class Denied extends Error {
override name = "Denied";
readonly code = "DENIED" as const;
}
type AppError = NotFound | Denied;
function advise(error: AppError): string {
switch (error.code) {
case "NOT_FOUND":
return "check the path";
case "DENIED":
return "check the permissions";
default: {
const unreachable: never = error;
return unreachable;
}
}
}
assertStrictEquals(advise(new NotFound("missing")), "check the path");
assertStrictEquals(advise(new Denied("locked")), "check the permissions");
});
telling a family of errors apart ... ok (0ms)
A literal code turns a family of error classes into a discriminated union, so the checker can prove the switch is total and will complain when somebody adds a third class, the machinery from the branching page's making the checker prove you covered every case built on the sentinels page's discriminants. An instanceof chain narrows too and can end in the same never check, so this is not about exhaustiveness alone. The difference is what the comparison depends on: error.code === "NOT_FOUND" compares two strings, while error instanceof NotFound requires you to be holding the same class object the error was built from, which the prototypes and inheritance page's instanceof asks about the chain, not construction showed is a stronger assumption than it looks.
what survives a boundary
Deno.test("what survives a boundary", () => {
class ConfigError extends Error {
override name = "ConfigError";
constructor(
message: string,
readonly path: string,
options?: ErrorOptions,
) {
super(message, options);
}
}
const original = new ConfigError("bad config", "/etc/app.json");
const copy = structuredClone(original);
assertFalse(copy instanceof ConfigError);
assert(copy instanceof Error);
assertStrictEquals(copy.name, "Error");
assertStrictEquals((copy as Partial<ConfigError>).path, undefined);
assertStrictEquals(copy.message, "bad config");
});
what survives a boundary ... ok (0ms)
Here is the honest version of the warning everyone repeats about instanceof being unreliable across boundaries, and the payoff four earlier pages deferred to this one. In Deno the boundary you actually have is a Worker, and values cross it by structured clone. The famous cross-realm failure does not happen: copy instanceof Error is true, because the clone is rebuilt with the receiving side's constructors. What breaks is narrower and more annoying: your subclass is flattened. Identity gone, the name override gone, the path gone, and a carefully designed error arrives as a plain Error with a message.
built-ins survive the boundary
This series has now watched a clone flatten a Price, a Tagged, a Carried, and a ConfigError. Predict what it does to a RangeError:
Deno.test("built-ins survive the boundary", () => {
assertStrictEquals(structuredClone(new RangeError("r")).name, "RangeError");
assertEquals(
structuredClone(new RangeError("r")) instanceof RangeError,
false,
);
const chained = structuredClone(
new Error("outer", { cause: new TypeError("inner") }),
);
assert(chained.cause instanceof TypeError);
assertStrictEquals(typeof structuredClone(new Error("s")).stack, "string");
});
Check programs/designing-error-types.test.ts
running 8 tests from ./programs/designing-error-types.test.ts
...
built-ins survive the boundary ... FAILED (8ms)
ERRORS
built-ins survive the boundary => ./programs/designing-error-types.test.ts:137:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- true
+ false
FAILURES
built-ins survive the boundary => ./programs/designing-error-types.test.ts:137:11
FAILED | 7 passed | 1 failed (13ms)
error: Test failed
Still a RangeError on the far side. The flattening rule this series drilled has an exception: built-in Error subclasses are part of what structured clone knows how to rebuild, so they survive with their class and their name. The cause chain survives too, along with the cause's own class, and stack survives as a string. Correct the prediction to assert(... instanceof RangeError):
built-ins survive the boundary ... ok (0ms)
The rule that falls out: if an error has to cross a structured-clone boundary and the far side needs more than a message, send a data object rather than an Error, and serialize the parts you care about yourself, because nothing else will.
AggregateError
Deno.test("AggregateError", () => {
const many = new AggregateError(
[new RangeError("a"), new TypeError("b")],
"both failed",
);
assertStrictEquals(many.name, "AggregateError");
assertStrictEquals(many.message, "both failed");
assertStrictEquals(many.errors.length, 2);
assert(many.errors[0] instanceof RangeError);
});
AggregateError ... ok (0ms)
One error carrying several, through .errors. In the standard library only Promise.any produces one, which the promise combinators page measures, so you will meet it before you throw it. Worth knowing it exists mostly so you do not invent an incompatible error.errors of your own.
a value instead of throwing
Deno.test("a value instead of throwing", () => {
type Parsed = { ok: true; value: number };
type Failed = { ok: false; code: "NOT_A_NUMBER" };
function parseAmount(text: string): Parsed | Failed {
const value = Number(text);
return Number.isNaN(value)
? { ok: false, code: "NOT_A_NUMBER" }
: { ok: true, value };
}
assertEquals(parseAmount("12.5"), { ok: true, value: 12.5 });
assertEquals(parseAmount("oak"), { ok: false, code: "NOT_A_NUMBER" });
});
a value instead of throwing ... ok (0ms)
Text that a user typed is not valid a good fraction of the time, so a failure there is not exceptional and does not need an exception: the result shape from the sentinels page puts it in the signature, and the checker makes every caller face it. Keep throw for the things that mean a bug or a broken world.
One last inventory, on which built-ins are yours to throw. TypeError and RangeError are, and readers understand them without explanation: the wrong kind of value, and a value out of bounds. SyntaxError, ReferenceError, and URIError are the engine's, and throwing one yourself makes a log entry look like a language-level fault when it was your validation, so the person reading it at three in the morning will chase the wrong thing. Error itself is always defensible and never misleading.
In practice
- Use one error class per boundary rather than per failure mode, with literal
codevalues distinguishing the cases. - Put a literal
codeon the error so comparison, serialization, and exhaustive switching remain reliable. - Chain the original error with
causeat every boundary, and put contextual data in separate properties. - Log the error object rather than only its message so the cause chain and stack remain visible.
- Send error data across worker boundaries instead of expecting a subclass instance to survive.
- Return a value instead of throwing when failure is a normal outcome callers must handle.