Frontend Master

JavaScript Curriculum

When Things Go Wrong
+45 XP

When Things Go Wrong

medium
~22 min·45 XP

Nexus fetches user data from an API. Sometimes the network fails. Sometimes the response is malformed JSON. Sometimes the user id doesn't exist. Right now the app crashes silently and users see a blank screen. A senior engineer is telling you: every operation that can fail, must have a plan for when it does.

Errors are inevitable — the question is how you handle them

Production code operates in a hostile environment. APIs go down. Users send unexpected input. Third-party libraries throw exceptions. Files don't exist. Network requests time out.

Unhandled errors crash your program and leave users with no information. Handled errors let your program recover, give the user useful feedback, and leave a precise diagnostic trail for you to debug later.

JavaScript provides a first-class system for this: try, catch, finally, and throw.


try / catch / finally

The structure is deliberately simple. You mark the code that might fail with try. You tell JavaScript what to do when it does fail with catch. You do any cleanup that must happen regardless with finally.

js
try { // risky code — might throw const data = JSON.parse(rawString) processData(data) } catch (err) { // runs only if try block threw // err is the Error object — err.message, err.stack console.error("Parsing failed:", err.message) } finally { // runs ALWAYS — whether try succeeded or catch ran // cleanup: close connections, release resources, reset state setLoading(false) }

Select a scenario below and watch exactly which lines execute and which are bypassed:

try / catch / finally — trace execution path
1try {
2const user = parseRawData(input)
3validateUser(user)
4} catch (err) {
5console.error(err.message)
6} finally {
7closeConnection()
8}

execution output

hit run to trace the execution path…

🧠try/catch is a safety net, not a silencer
The goal of a catch block isn't to make errors disappear — it's to handle them intentionally. Swallowing an error without logging or responding to it (an empty catch block) is one of the most dangerous patterns in programming. Always do something with the error: log it, show a message, return a fallback value, or re-throw it.

The Error object

When an error is thrown, it comes with a standard structure. The catch parameter gives you direct access to it:

js
try { null.name // throws TypeError } catch (err) { console.log(err.name) // "TypeError" console.log(err.message) // "Cannot read properties of null (reading 'name')" console.log(err.stack) // full stack trace — invaluable for debugging }

Every built-in error inherits from Error. You can also create your own:

js
const err = new Error("Something went wrong") err.name // "Error" err.message // "Something went wrong" err.stack // stack trace from where new Error() was called

Native error types

JavaScript has six built-in error types. Knowing which one you're dealing with tells you exactly what went wrong:

error types — click to explore each one

TypeError

Wrong type of thing for this operation

common triggers

Calling a non-function, accessing properties on null/undefined, wrong method for the type

example code

null.name
// TypeError: Cannot read properties of null

const n = 42
n.toUpperCase()
// TypeError: n.toUpperCase is not a function

how to catch

try { ... } catch (err) {
  if (err instanceof TypeError) {
    // handle it
  }
}

💡 in practice

The most common runtime error. Usually means you assumed something was an object or function when it was actually null, undefined, or a primitive.

The two you'll encounter constantly are TypeError (wrong type — usually null/undefined access) and ReferenceError (variable doesn't exist — usually a typo or scope bug). The others are more situational.


Throwing your own errors

throw terminates the current execution and sends an error up the call stack until something catches it. You can throw anything, but always throw an Error object so the caller gets a stack trace:

js
function getUser(id) { if (!id) throw new TypeError("id is required") if (id < 0) throw new RangeError("id must be positive") if (!db.exists(id)) throw new Error(`User ${id} not found`) return db.fetch(id) }

You can create custom error classes for domain-specific problems — highly useful in larger codebases:

js
class ValidationError extends Error { constructor(field, message) { super(message) this.name = 'ValidationError' this.field = field // extra context for the caller } } function validateUser(user) { if (!user.name) throw new ValidationError('name', 'Name is required') if (!user.email) throw new ValidationError('email', 'Email is required') } try { validateUser({ name: "Alex" }) } catch (err) { if (err instanceof ValidationError) { console.error(`Field "${err.field}": ${err.message}`) // "Field "email": Email is required" } else { throw err // re-throw errors you didn't expect } }

return null vs throw — a crucial design decision

When a function can't complete its job, it faces a fork: return a null/undefined sentinel value and leave it to the caller to notice, or throw an error that forces the caller to deal with it.

Step through both approaches below:

return null vs throw — which is better?

getUser function

function getUser(id) {
if (!id) return null // ← silent failure
return fetchFromDB(id)
}

calling code

const user = getUser(null)
console.log(user.name) // ← crash here
// but root cause was getUser

Step through to trace what happens when getUser receives a null id.

not started
js
// ✗ return null — silent failure function getUser(id) { if (!id) return null // caller might not check this } const user = getUser(null) user.name // TypeError crashes here, not in getUser // the real cause is obscured // ✓ throw — explicit failure with context function getUser(id) { if (!id) throw new Error("id is required") } try { const user = getUser(null) } catch (err) { console.error(err.message) // "id is required" — exact cause, exact location }
💡Throw early, catch at the right level
Throw at the lowest level where you know something is wrong. Catch at the level where you can do something useful — show a message to the user, retry the request, log and continue. Never catch an error just to ignore it.

The finally block — guaranteed cleanup

finally runs regardless of whether try succeeded or catch handled an error. It's essential for cleanup that must happen no matter what — closing database connections, releasing locks, resetting UI state:

js
async function fetchUser(id) { setLoading(true) // show spinner try { const response = await fetch(`/api/users/${id}`) const user = await response.json() return user } catch (err) { console.error("Fetch failed:", err.message) return null } finally { setLoading(false) // ← always hides the spinner, success or failure } }

Without finally, a thrown error in the try block would leave the spinner showing forever.


Defensive JSON parsing — your challenge

JSON.parse throws a SyntaxError on invalid input. That makes it one of the most common places you'll write try/catch in real JavaScript code.

js
// The pattern you'll implement: function parseUserData(jsonString) { try { const user = JSON.parse(jsonString) if (!user.name) throw new Error("Missing required field: name") return user } catch (err) { console.error("parseUserData failed:", err.message) return null } }

Your implementation needs to handle three cases cleanly — valid data passes through, invalid JSON is caught, and missing fields are validated and thrown before they cause problems downstream.

Challenge

Write a function called parseUserData(jsonString) that uses try/catch to safely parse a JSON string into a user object. If parsing succeeds, validate that the result has a name property — if it doesn't, throw a new Error with the message \"Missing required field: name\". If anything fails, catch the error, log it with console.error, and return null. Test it with valid JSON, invalid JSON, and JSON missing the name field.

error-handlingtry-catch-finallythrowErrorTypeErrorReferenceErrordefensive-programming
When Things Go Wrong | Nexus Learn