Errors
One class, one code set, one subpath. Branch on error.code, never on the message.
Note
ELI5. When something goes wrong, this library throws one kind of error, and that error carries a short label saying what kind of wrong it was. Read the label, not the sentence: the sentence is for a human reading a log and may be reworded, while the label is a promise. And note what does not throw: a bad signature, a wrong code, a missing header. Those come back as false, because a request being wrong is not an exceptional event on a server.
import { UnsecureError } from "unsecure/errors";Everything the library throws is an UnsecureError. A native TypeError, RangeError or SyntaxError escaping from unsecure is a bug. Report it.
#Signature
type UnsecureErrorCode =
"INVALID_TYPE" | "OUT_OF_RANGE" | "MALFORMED" | "UNSUPPORTED" | "FROZEN" | "PLATFORM";
class UnsecureError extends Error {
readonly name: "UnsecureError";
readonly code: UnsecureErrorCode;
readonly cause?: unknown;
constructor(code: UnsecureErrorCode, message: string, options?: { cause?: unknown });
}message names the function, the value it judged and what it expected, as in "hkdf: length must be an integer between 1 and 8160, got 0.". code is that same judgement, machine-readable. cause is set only when the failure came from outside the library.
#The codes
| Code | Meaning | Where it comes from |
|---|---|---|
INVALID_TYPE | A value of the wrong JavaScript type, or nothing where a value is required. | Anything taking a BytesSource; secureCompare's expected; a non-string algorithm; otpauthURI's type, account and issuer; uuidv7's timestamp; every codec stringify and parse. |
OUT_OF_RANGE | The right type, outside its documented domain. | An empty secret; every bounded number in hkdf, otp, generate and random; a CryptoKey of the wrong kind; a base32 alphabet that is not 32 distinct ASCII characters; overlapping generator character sets; secureCompare under strict. |
MALFORMED | Text that is not what it claims to be. | A codec parse on anything but a canonical encoding, or decoded bytes that are not valid UTF-8; safeJsonParse on JSON that does not parse; uuidv7Timestamp on a non-v7 string; argon2Verify on a string that is not a PHC string. |
UNSUPPORTED | A name outside the set the library accepts. | Any algorithm; any returnAs; otpauthURI for a type other than "hotp" or "totp"; an unknown Argon2 variant or a PHC version other than 0x13. |
FROZEN | A dangerous key cannot be removed because the object holding it is frozen or sealed. | sanitizeObject, and safeJsonParse through it. |
PLATFORM | The runtime's Web Crypto refused an operation the library had already validated. | hash, hmac, hmacVerify, hkdf and the OTP functions built on them. cause carries the platform error. |
The union is complete for this release. A later minor may add a code, so keep a default branch in any switch over it.
#What throws and what does not
Verification functions never throw for untrusted input. secureCompare, hmacVerify, hotpVerify, totpVerify and argon2Verify return false — or { valid: false } — for a missing, malformed or wrong-typed value off the wire: a null header, a signature that is not canonical hex, a code that is not a string.
They throw only for something you control: an empty secret, an unsupported algorithm, an out-of-range window, a stored hash in a format nobody wrote.
So a catch around a verify is about your own configuration, never about the request.
#Catching
import { UnsecureError } from "unsecure/errors";
import { hmacVerify } from "unsecure/hmac";
try {
const valid = await hmacVerify(secret, body, request.headers.get("x-signature"));
return valid ? handle(body) : respond(403);
} catch (error) {
if (!(error instanceof UnsecureError)) throw error;
switch (error.code) {
case "OUT_OF_RANGE": {
// An empty secret: the deployment is misconfigured, the request is fine
return respond(500);
}
case "PLATFORM": {
// error.cause is the runtime's own failure
return respond(503);
}
default: {
return respond(400, { reason: error.code });
}
}
}The codecs report exactly where the text stopped being canonical, which makes their message safe and useful to return:
import { UnsecureError, base64Parse } from "unsecure";
try {
base64Parse(untrusted);
} catch (error) {
if (error instanceof UnsecureError && error.code === "MALFORMED") {
return respond(400, { reason: error.message });
}
throw error;
}#Mapping codes onto HTTP
A reasonable default, to adapt:
| Code | Status | Why |
|---|---|---|
MALFORMED | 400 | The client sent text that is not what it claimed to be. |
INVALID_TYPE | 400 or 500 | 400 if the value came from the request body; 500 if it came from your own code. |
OUT_OF_RANGE | 500 | Usually a configuration value that did not load. |
UNSUPPORTED | 500 | You named an algorithm or a returnAs the library does not have. |
FROZEN | 500 | A frozen object reached a sanitizer. Use sanitizeObjectCopy. |
PLATFORM | 503 | The runtime refused an operation it should have performed. |
#Pitfall: matching on message text
// Breaks the moment the wording improves
if (error.message.includes("must not be empty")) { … }
// Codes are the contract
if (error instanceof UnsecureError && error.code === "OUT_OF_RANGE") { … }Several messages gained a function-name prefix in 0.3. See Migrating to 0.3.
#Pitfall: an exhaustive switch with no default
The code union is documented as growable. A switch with no default compiles today and silently does nothing the day a code is added.
switch (error.code) {
case "MALFORMED":
return respond(400);
default:
return respond(500);
}#Pitfall: losing cause
PLATFORM errors, and safeJsonParse's MALFORMED, carry the original failure in cause. Log it. The library's message says which operation was refused; cause says why.