JavaScript · Syntax · Intermediate

Custom errors and error handling

10 tasks

Built-in error types, custom Error subclasses, and structured error handling patterns.

Error types, hierarchy, Error.stack, and custom error classes

#
**The Error hierarchy** Every thrown error in JavaScript is (or should be) an instance of `Error` or one of its subclasses: ```javascript Error // base class — used for custom errors ├── SyntaxError // invalid JS syntax (usually at parse time) ├── ReferenceError // variable not found ├── TypeError // wrong type for an operation ├── RangeError // value out of allowed range ├── URIError // malformed URI └── EvalError // error inside eval() (rarely seen) ``` **Creating and throwing errors** ```javascript throw new Error('Something went wrong'); // generic throw new TypeError('Expected a string'); // specific throw new RangeError('Index out of bounds'); // specific // You can throw anything, but an Error object gives you a stack trace: throw 'oops'; // works, but loses stack trace — avoid this throw { code: 404 }; // same problem — use new Error() instead ``` **Error properties** ```javascript try { null.property; // TypeError } catch (err) { console.log(err.name); // 'TypeError' console.log(err.message); // "Cannot read properties of null (reading 'property')" console.log(err.stack); // TypeError: Cannot read properties of null (reading 'property') // at <anonymous>:2:8 // at ... } ``` `err.stack` is a string containing the error message plus the call stack — invaluable for debugging. It's not part of the spec but all major engines provide it. **err.name vs constructor.name** ```javascript const e = new TypeError('bad'); e.name === 'TypeError' // true — the error type label e.constructor.name === 'TypeError' // also true — but only if the class isn't minified // Prefer checking err.name in catch blocks: if (err instanceof TypeError) { ... } // cleanest — uses prototype chain if (err.name === 'TypeError') { ... } // also fine // Don't rely on err.constructor.name in production (minification can change it) ``` **Custom error classes** ```javascript class ValidationError extends Error { constructor(message, field) { super(message); this.name = 'ValidationError'; // override — important! this.field = field; } } throw new ValidationError('Required field missing', 'email'); // In catch: if (err instanceof ValidationError) { console.log(`Field '${err.field}': ${err.message}`); } ``` Always set `this.name` in custom subclasses — otherwise `err.name` inherits 'Error' from the base class, which breaks string-based type checking.

try/catch/finally, re-throwing, and unhandled promise rejections

#
**try / catch / finally — the fundamentals** ```javascript try { const data = JSON.parse(input); // may throw SyntaxError processData(data); // may throw other errors } catch (err) { console.error('Failed:', err.message); } finally { cleanup(); // runs whether or not an error was thrown } ``` `finally` always runs — even if the `try` block has a `return` statement. Use it for cleanup: closing files, releasing locks, resetting loading state. **Catching specific error types** ```javascript try { riskyOperation(); } catch (err) { if (err instanceof TypeError) { console.error('Type problem:', err.message); } else if (err instanceof ValidationError) { showFieldError(err.field, err.message); } else { throw err; // re-throw errors you can't handle } } ``` **Re-throw — don't swallow errors you can't handle** A common mistake is catching all errors silently: ```javascript // BAD — hides bugs: try { riskyOp(); } catch (err) { /* ignored */ } // BETTER — handle what you can, re-throw the rest: try { riskyOp(); } catch (err) { if (err instanceof NetworkError) { return fallbackData(); } throw err; // something unexpected — let it propagate } ``` **Promise rejection vs synchronous throw** Errors inside `async` functions become rejected promises: ```javascript async function loadUser(id) { const user = await fetchUser(id); // throws if fetch fails → rejects the promise return user; } // Must catch at the call site: loadUser(42) .then(user => render(user)) .catch(err => showError(err.message)); // Or with await + try/catch: try { const user = await loadUser(42); } catch (err) { showError(err.message); } ``` **Unhandled promise rejections** ```javascript // This rejection goes unhandled — often becomes a hard crash in Node.js: fetch('/api/data'); // forgot .catch() // Detect them globally: window.addEventListener('unhandledrejection', (event) => { console.error('Unhandled rejection:', event.reason); event.preventDefault(); // prevents browser console warning }); // In Node.js: process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); }); ```

Error handling strategies: throw vs Result pattern, Error.cause

#
**Error handling strategies** Three patterns exist for communicating failures. Each has a place: ``` 1. Throw — for unexpected/unrecoverable errors (programming bugs) 2. Return null/undefined — simple sentinel, but loses error details 3. Result object {ok, error} — explicit, typed, forces the caller to check ``` **The Result pattern** Instead of throwing for expected failures, return a discriminated union: ```javascript function parseUserInput(str) { if (!str.trim()) { return { ok: false, error: 'Input is empty' }; } const num = Number(str); if (isNaN(num)) { return { ok: false, error: `'${str}' is not a number` }; } return { ok: true, value: num }; } const result = parseUserInput(userStr); if (!result.ok) { showError(result.error); } else { process(result.value); } ``` Use the Result pattern for **expected failures** (form validation, parsing, API responses that may not exist). Use `throw` for **unexpected state** (programmer errors, violated invariants). **Error boundary pattern — top-level async handler** ```javascript async function main() { try { const config = await loadConfig(); await startServer(config); } catch (err) { console.error('Fatal error:', err); process.exit(1); } } main(); ``` **Wrapping errors to preserve context** ES2022 added `Error.cause` for chaining errors: ```javascript async function loadUserProfile(id) { try { return await db.users.findById(id); } catch (err) { throw new Error(`Failed to load user ${id}`, { cause: err }); } } // In catch: console.error(err.message); // 'Failed to load user 42' console.error(err.cause.message); // original DB error message ``` **Quick decision guide** ``` Scenario Recommended approach ────────────────────────────── ──────────────────────────────── User input validation Result pattern {ok, error} Network/DB failures throw (async boundary catches) Programmer error (assert-like) throw Error with descriptive msg Optional data that may not exist return null (simple case) Multiple possible failure modes Result with typed error ```
01

#

Write a function that safely divides two numbers. If the divisor is 0, throw a `RangeError` with message 'Division by zero'.

function safeDivide(a, b) {
    // your code here
}

console.log(safeDivide(10, 2));  // 5
try {
    safeDivide(10, 0);
} catch (e) {
    console.log(e instanceof RangeError);  // true
    console.log(e.message);                // 'Division by zero'
}
Solution
function safeDivide(a, b) {
    if (b === 0) throw new RangeError('Division by zero');
    return a / b;
}
02

#

Create a custom error class `ValidationError` that extends `Error`. It should accept a field name and message, storing them as properties.

class ValidationError extends Error {
    constructor(field, message) {
        // your code here
    }
}

const e = new ValidationError('email', 'Invalid format');
console.log(e instanceof ValidationError);  // true
console.log(e instanceof Error);            // true
console.log(e.field);                       // 'email'
console.log(e.message);                     // 'Invalid format'
console.log(e.name);                        // 'ValidationError'
Solution
class ValidationError extends Error {
    constructor(field, message) {
        super(message);
        this.name = 'ValidationError';
        this.field = field;
    }
}
03

#

Write a `try/catch/finally` block that parses a JSON string. If parsing fails, return a default value `{}`. The `finally` block should log 'parse attempted'.

function safeParse(str) {
    // your code here
}

console.log(safeParse('{"a":1}'));   // { a: 1 }
console.log(safeParse('invalid'));    // {}
// 'parse attempted' should be logged in both cases
Solution
function safeParse(str) {
    try {
        return JSON.parse(str);
    } catch (e) {
        return {};
    } finally {
        console.log('parse attempted');
    }
}
04

#

Write a function that takes an array and an index. If the index is out of bounds, throw a `RangeError`. If the argument is not an array, throw a `TypeError`.

function getElement(arr, index) {
    // your code here
}

console.log(getElement([1, 2, 3], 1));  // 2
try { getElement('not array', 0); } catch(e) { console.log(e.constructor.name); } // TypeError
try { getElement([1,2], 5); } catch(e) { console.log(e.constructor.name); }       // RangeError
Solution
function getElement(arr, index) {
    if (!Array.isArray(arr)) throw new TypeError('Expected an array');
    if (index < 0 || index >= arr.length) throw new RangeError(`Index ${index} out of bounds`);
    return arr[index];
}
05

#

Write a function that catches a specific error type and re-throws any other errors it doesn't know how to handle.

class NetworkError extends Error {
    constructor(msg) { super(msg); this.name = 'NetworkError'; }
}

function handleRequest(fn) {
    // Call fn(). If it throws NetworkError, return 'network error handled'.
    // If it throws anything else, re-throw it.
}

console.log(handleRequest(() => { throw new NetworkError('timeout'); }));
// 'network error handled'
try {
    handleRequest(() => { throw new TypeError('bad type'); });
} catch (e) {
    console.log(e instanceof TypeError);  // true
}
Solution
class NetworkError extends Error {
    constructor(msg) { super(msg); this.name = 'NetworkError'; }
}

function handleRequest(fn) {
    try {
        return fn();
    } catch (e) {
        if (e instanceof NetworkError) return 'network error handled';
        throw e;
    }
}
06

#

Handle async errors: write an async function that fetches a URL (simulated). If the fetch fails, log the error message and return null.

async function simulatedFetch(url) {
    if (!url.startsWith('https')) throw new Error(`Invalid URL: ${url}`);
    return { status: 200, data: 'ok' };
}

async function safeFetch(url) {
    // your code here
}

safeFetch('https://example.com').then(console.log);  // { status: 200, data: 'ok' }
safeFetch('http://bad.com').then(console.log);       // null (and logs the error)
Solution
async function safeFetch(url) {
    try {
        return await simulatedFetch(url);
    } catch (e) {
        console.error(e.message);
        return null;
    }
}
07

#

Write a function that wraps another function and catches any TypeError, returning a default value instead. Other error types should propagate normally.

function withTypeErrorFallback(fn, defaultValue) {
    // your code here
}

const safe = withTypeErrorFallback(() => null.length, 0);
console.log(safe);  // 0

try {
    withTypeErrorFallback(() => { throw new RangeError('oops'); }, 0);
} catch (e) {
    console.log(e instanceof RangeError);  // true
}
Solution
function withTypeErrorFallback(fn, defaultValue) {
    try {
        return fn();
    } catch (e) {
        if (e instanceof TypeError) return defaultValue;
        throw e;
    }
}
08

#

Create an `ErrorRegistry` class that records all errors thrown during batch processing. It should collect them instead of stopping at the first one.

class ErrorRegistry {
    constructor() {
        this.errors = [];
    }

    run(fn) {
        // Call fn(). If it throws, record the error. Never re-throw.
    }

    hasErrors() { return this.errors.length > 0; }
    getErrors() { return this.errors; }
}

const reg = new ErrorRegistry();
reg.run(() => { /* ok */ });
reg.run(() => { throw new Error('first'); });
reg.run(() => { throw new TypeError('second'); });
console.log(reg.hasErrors());           // true
console.log(reg.getErrors().length);    // 2
Solution
class ErrorRegistry {
    constructor() {
        this.errors = [];
    }

    run(fn) {
        try {
            fn();
        } catch (e) {
            this.errors.push(e);
        }
    }

    hasErrors() { return this.errors.length > 0; }
    getErrors() { return this.errors; }
}
09

#

Implement error chaining: create a function that wraps a low-level error in a higher-level one, preserving the original error as `cause`.

function readConfig(filename) {
    throw new Error(`File not found: ${filename}`);
}

function loadApp(configPath) {
    // Try readConfig(configPath). If it fails, throw a new Error
    // 'Failed to load application' with the original as 'cause'
}

try {
    loadApp('config.json');
} catch (e) {
    console.log(e.message);        // 'Failed to load application'
    console.log(e.cause.message);  // 'File not found: config.json'
}
Solution
function readConfig(filename) {
    throw new Error(`File not found: ${filename}`);
}

function loadApp(configPath) {
    try {
        return readConfig(configPath);
    } catch (e) {
        throw new Error('Failed to load application', { cause: e });
    }
}
10

#

Write an `assertType` function that throws a `TypeError` with a descriptive message if a value is not of the expected type (checked with `typeof`).

function assertType(value, expectedType) {
    // your code here
}

assertType(42, 'number');       // ok, no throw
assertType('hello', 'string');  // ok
try {
    assertType(42, 'string');
} catch (e) {
    console.log(e instanceof TypeError);  // true
    console.log(e.message);  // e.g. 'Expected string, got number'
}
Solution
function assertType(value, expectedType) {
    const actual = typeof value;
    if (actual !== expectedType) {
        throw new TypeError(`Expected ${expectedType}, got ${actual}`);
    }
}