DEV Community

Jeremiah Deku
Jeremiah Deku

Posted on

The exact error handling pattern I use in every Express.js + TypeScript REST API

Most Node.js error handling tutorials show you try/catch.
That's not enough for production.
I learned this the hard way. A silent catch block swallowed a database failure. My API returned 200. The user's data never saved. Nobody knew for 48 hours.
After that incident, I built a pattern I now wire into every Express + TypeScript REST API I ship. It has four parts. Every part earns its place.

Here's exactly what it looks like — and why each piece matters.


𝗧𝗵𝗲 𝗽𝗿𝗼𝗯𝗹𝗲𝗺 𝘄𝗶𝘁𝗵 𝗱𝗲𝗳𝗮𝘂𝗹𝘁 𝗘𝘅𝗽𝗿𝗲𝘀𝘀 𝗲𝗿𝗿𝗼𝗿 𝗵𝗮𝗻𝗱𝗹𝗶𝗻𝗴
Out of the box, Express does this when something throws:

𝘢𝘱𝘱.𝘨𝘦𝘵('/𝘶𝘴𝘦𝘳𝘴/:𝘪𝘥', 𝘢𝘴𝘺𝘯𝘤 (𝘳𝘦𝘲, 𝘳𝘦𝘴) => {
𝘤𝘰𝘯𝘴𝘵 𝘶𝘴𝘦𝘳 = 𝘢𝘸𝘢𝘪𝘵 𝘥𝘣.𝘧𝘪𝘯𝘥𝘉𝘺𝘐𝘥(𝘳𝘦𝘲.𝘱𝘢𝘳𝘢𝘮𝘴.𝘪𝘥); // 𝘵𝘩𝘳𝘰𝘸𝘴? 𝘤𝘳𝘢𝘴𝘩𝘦𝘴 𝘵𝘩𝘦 𝘱𝘳𝘰𝘤𝘦𝘴𝘴
𝘳𝘦𝘴.𝘫𝘴𝘰𝘯(𝘶𝘴𝘦𝘳);
});

If db.findById throws, Express doesn't catch it in async route handlers by default. Your server either crashes or hangs. The client waits forever.
Even if you add try/catch:
𝘢𝘱𝘱.𝘨𝘦𝘵('/𝘶𝘴𝘦𝘳𝘴/:𝘪𝘥', 𝘢𝘴𝘺𝘯𝘤 (𝘳𝘦𝘲, 𝘳𝘦𝘴) => {
𝘵𝘳𝘺 {
𝘤𝘰𝘯𝘴𝘵 𝘶𝘴𝘦𝘳 = 𝘢𝘸𝘢𝘪𝘵 𝘥𝘣.𝘧𝘪𝘯𝘥𝘉𝘺𝘐𝘥(𝘳𝘦𝘲.𝘱𝘢𝘳𝘢𝘮𝘴.𝘪𝘥);
𝘳𝘦𝘴.𝘫𝘴𝘰𝘯(𝘶𝘴𝘦𝘳);
} 𝘤𝘢𝘵𝘤𝘩 (𝘦𝘳𝘳) {
𝘳𝘦𝘴.𝘴𝘵𝘢𝘵𝘶𝘴(500).𝘫𝘴𝘰𝘯({ 𝘦𝘳𝘳𝘰𝘳: 𝘦𝘳𝘳.𝘮𝘦𝘴𝘴𝘢𝘨𝘦 }); // 𝘭𝘦𝘢𝘬𝘴 𝘪𝘯𝘵𝘦𝘳𝘯𝘢𝘭𝘴
}
});

Now you're leaking internal error messages to clients, losing stack traces in logs, and duplicating error logic across every route.
Here's what I do instead.


𝗣𝗮𝗿𝘁 𝟭: 𝗔 𝘁𝘆𝗽𝗲𝗱 𝗔𝗽𝗽𝗘𝗿𝗿𝗼𝗿 𝗰𝗹𝗮𝘀𝘀
Every error in the system extends a single base class. No raw new Error() anywhere in the codebase.
// 𝘴𝘳𝘤/𝘦𝘳𝘳𝘰𝘳𝘴/𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳.𝘵𝘴

𝘦𝘹𝘱𝘰𝘳𝘵 𝘤𝘭𝘢𝘴𝘴 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 𝘦𝘹𝘵𝘦𝘯𝘥𝘴 𝘌𝘳𝘳𝘰𝘳 {
𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳(
𝘱𝘶𝘣𝘭𝘪𝘤 𝘳𝘦𝘢𝘥𝘰𝘯𝘭𝘺 𝘮𝘦𝘴𝘴𝘢𝘨𝘦: 𝘴𝘵𝘳𝘪𝘯𝘨,
𝘱𝘶𝘣𝘭𝘪𝘤 𝘳𝘦𝘢𝘥𝘰𝘯𝘭𝘺 𝘴𝘵𝘢𝘵𝘶𝘴𝘊𝘰𝘥𝘦: 𝘯𝘶𝘮𝘣𝘦𝘳,
𝘱𝘶𝘣𝘭𝘪𝘤 𝘳𝘦𝘢𝘥𝘰𝘯𝘭𝘺 𝘤𝘰𝘥𝘦: 𝘴𝘵𝘳𝘪𝘯𝘨,
𝘱𝘶𝘣𝘭𝘪𝘤 𝘳𝘦𝘢𝘥𝘰𝘯𝘭𝘺 𝘪𝘴𝘖𝘱𝘦𝘳𝘢𝘵𝘪𝘰𝘯𝘢𝘭: 𝘣𝘰𝘰𝘭𝘦𝘢𝘯 = 𝘵𝘳𝘶𝘦
) {
𝘴𝘶𝘱𝘦𝘳(𝘮𝘦𝘴𝘴𝘢𝘨𝘦);
𝘵𝘩𝘪𝘴.𝘯𝘢𝘮𝘦 = 𝘵𝘩𝘪𝘴.𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳.𝘯𝘢𝘮𝘦;
𝘌𝘳𝘳𝘰𝘳.𝘤𝘢𝘱𝘵𝘶𝘳𝘦𝘚𝘵𝘢𝘤𝘬𝘛𝘳𝘢𝘤𝘦(𝘵𝘩𝘪𝘴, 𝘵𝘩𝘪𝘴.𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳);
}
}

𝘦𝘹𝘱𝘰𝘳𝘵 𝘤𝘭𝘢𝘴𝘴 𝘕𝘰𝘵𝘍𝘰𝘶𝘯𝘥𝘌𝘳𝘳𝘰𝘳 𝘦𝘹𝘵𝘦𝘯𝘥𝘴 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 {
𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳(𝘳𝘦𝘴𝘰𝘶𝘳𝘤𝘦: 𝘴𝘵𝘳𝘪𝘯𝘨) {
𝘴𝘶𝘱𝘦𝘳(${𝘳𝘦𝘴𝘰𝘶𝘳𝘤𝘦} 𝘯𝘰𝘵 𝘧𝘰𝘶𝘯𝘥, 404, '𝘕𝘖𝘛_𝘍𝘖𝘜𝘕𝘋');
}
}

𝘦𝘹𝘱𝘰𝘳𝘵 𝘤𝘭𝘢𝘴𝘴 𝘝𝘢𝘭𝘪𝘥𝘢𝘵𝘪𝘰𝘯𝘌𝘳𝘳𝘰𝘳 𝘦𝘹𝘵𝘦𝘯𝘥𝘴 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 {
𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳(𝘮𝘦𝘴𝘴𝘢𝘨𝘦: 𝘴𝘵𝘳𝘪𝘯𝘨) {
𝘴𝘶𝘱𝘦𝘳(𝘮𝘦𝘴𝘴𝘢𝘨𝘦, 422, '𝘝𝘈𝘓𝘐𝘋𝘈𝘛𝘐𝘖𝘕_𝘌𝘙𝘙𝘖𝘙');
}
}

𝘦𝘹𝘱𝘰𝘳𝘵 𝘤𝘭𝘢𝘴𝘴 𝘜𝘯𝘢𝘶𝘵𝘩𝘰𝘳𝘪𝘻𝘦𝘥𝘌𝘳𝘳𝘰𝘳 𝘦𝘹𝘵𝘦𝘯𝘥𝘴 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 {
𝘤𝘰𝘯𝘴𝘵𝘳𝘶𝘤𝘵𝘰𝘳() {
𝘴𝘶𝘱𝘦𝘳('𝘜𝘯𝘢𝘶𝘵𝘩𝘰𝘳𝘪𝘻𝘦𝘥', 401, '𝘜𝘕𝘈𝘜𝘛𝘏𝘖𝘙𝘐𝘡𝘌𝘋');
}
}

isOperational is the key flag. true means this is an expected failure — a user not found, a bad payload. false means something unexpected broke — a DB connection dropped, a third-party SDK crashed. Your error handler treats these differently.


𝗣𝗮𝗿𝘁 𝟮: 𝗔 𝗰𝗲𝗻𝘁𝗿𝗮𝗹 𝗲𝗿𝗿𝗼𝗿 𝗵𝗮𝗻𝗱𝗹𝗲𝗿 𝗺𝗶𝗱𝗱𝗹𝗲𝘄𝗮𝗿𝗲

// 𝘴𝘳𝘤/𝘮𝘪𝘥𝘥𝘭𝘦𝘸𝘢𝘳𝘦/𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳.𝘵𝘴
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘙𝘦𝘲𝘶𝘦𝘴𝘵, 𝘙𝘦𝘴𝘱𝘰𝘯𝘴𝘦, 𝘕𝘦𝘹𝘵𝘍𝘶𝘯𝘤𝘵𝘪𝘰𝘯 } 𝘧𝘳𝘰𝘮 '𝘦𝘹𝘱𝘳𝘦𝘴𝘴';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 } 𝘧𝘳𝘰𝘮 '../𝘦𝘳𝘳𝘰𝘳𝘴/𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘭𝘰𝘨𝘨𝘦𝘳 } 𝘧𝘳𝘰𝘮 '../𝘭𝘪𝘣/𝘭𝘰𝘨𝘨𝘦𝘳';

𝘦𝘹𝘱𝘰𝘳𝘵 𝘧𝘶𝘯𝘤𝘵𝘪𝘰𝘯 𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳(
𝘦𝘳𝘳: 𝘌𝘳𝘳𝘰𝘳,
𝘳𝘦𝘲: 𝘙𝘦𝘲𝘶𝘦𝘴𝘵,
𝘳𝘦𝘴: 𝘙𝘦𝘴𝘱𝘰𝘯𝘴𝘦,
𝘯𝘦𝘹𝘵: 𝘕𝘦𝘹𝘵𝘍𝘶𝘯𝘤𝘵𝘪𝘰𝘯
): 𝘷𝘰𝘪𝘥 {
𝘤𝘰𝘯𝘴𝘵 𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥 = 𝘳𝘦𝘲.𝘩𝘦𝘢𝘥𝘦𝘳𝘴['𝘹-𝘳𝘦𝘲𝘶𝘦𝘴𝘵-𝘪𝘥'] 𝘢𝘴 𝘴𝘵𝘳𝘪𝘯𝘨;

𝘪𝘧 (𝘦𝘳𝘳 𝘪𝘯𝘴𝘵𝘢𝘯𝘤𝘦𝘰𝘧 𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳 && 𝘦𝘳𝘳.𝘪𝘴𝘖𝘱𝘦𝘳𝘢𝘵𝘪𝘰𝘯𝘢𝘭) {
// 𝘌𝘹𝘱𝘦𝘤𝘵𝘦𝘥 𝘦𝘳𝘳𝘰𝘳 — 𝘭𝘰𝘨 𝘭𝘪𝘨𝘩𝘵𝘭𝘺, 𝘳𝘦𝘴𝘱𝘰𝘯𝘥 𝘤𝘭𝘦𝘢𝘯𝘭𝘺
𝘭𝘰𝘨𝘨𝘦𝘳.𝘸𝘢𝘳𝘯({
𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥,
𝘤𝘰𝘥𝘦: 𝘦𝘳𝘳.𝘤𝘰𝘥𝘦,
𝘮𝘦𝘴𝘴𝘢𝘨𝘦: 𝘦𝘳𝘳.𝘮𝘦𝘴𝘴𝘢𝘨𝘦,
𝘴𝘵𝘢𝘵𝘶𝘴𝘊𝘰𝘥𝘦: 𝘦𝘳𝘳.𝘴𝘵𝘢𝘵𝘶𝘴𝘊𝘰𝘥𝘦,
});

𝘳𝘦𝘴.𝘴𝘵𝘢𝘵𝘶𝘴(𝘦𝘳𝘳.𝘴𝘵𝘢𝘵𝘶𝘴𝘊𝘰𝘥𝘦).𝘫𝘴𝘰𝘯({
  𝘴𝘵𝘢𝘵𝘶𝘴: '𝘦𝘳𝘳𝘰𝘳',
  𝘤𝘰𝘥𝘦: 𝘦𝘳𝘳.𝘤𝘰𝘥𝘦,
  𝘮𝘦𝘴𝘴𝘢𝘨𝘦: 𝘦𝘳𝘳.𝘮𝘦𝘴𝘴𝘢𝘨𝘦,
});
𝘳𝘦𝘵𝘶𝘳𝘯;
Enter fullscreen mode Exit fullscreen mode

}

// 𝘜𝘯𝘦𝘹𝘱𝘦𝘤𝘵𝘦𝘥 𝘦𝘳𝘳𝘰𝘳 — 𝘭𝘰𝘨 𝘦𝘷𝘦𝘳𝘺𝘵𝘩𝘪𝘯𝘨, 𝘳𝘦𝘴𝘱𝘰𝘯𝘥 𝘨𝘦𝘯𝘦𝘳𝘪𝘤𝘢𝘭𝘭𝘺
𝘭𝘰𝘨𝘨𝘦𝘳.𝘦𝘳𝘳𝘰𝘳({
𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥,
𝘮𝘦𝘴𝘴𝘢𝘨𝘦: 𝘦𝘳𝘳.𝘮𝘦𝘴𝘴𝘢𝘨𝘦,
𝘴𝘵𝘢𝘤𝘬: 𝘦𝘳𝘳.𝘴𝘵𝘢𝘤𝘬,
𝘯𝘢𝘮𝘦: 𝘦𝘳𝘳.𝘯𝘢𝘮𝘦,
});

𝘳𝘦𝘴.𝘴𝘵𝘢𝘵𝘶𝘴(500).𝘫𝘴𝘰𝘯({
𝘴𝘵𝘢𝘵𝘶𝘴: '𝘦𝘳𝘳𝘰𝘳',
𝘤𝘰𝘥𝘦: '𝘐𝘕𝘛𝘌𝘙𝘕𝘈𝘓_𝘌𝘙𝘙𝘖𝘙',
𝘮𝘦𝘴𝘴𝘢𝘨𝘦: '𝘚𝘰𝘮𝘦𝘵𝘩𝘪𝘯𝘨 𝘸𝘦𝘯𝘵 𝘸𝘳𝘰𝘯𝘨. 𝘗𝘭𝘦𝘢𝘴𝘦 𝘵𝘳𝘺 𝘢𝘨𝘢𝘪𝘯.',
});
}

Clients never see stack traces, internal messages, or database query strings. Logs capture everything you need to debug. The requestId ties the log line back to the exact request that failed.


𝗣𝗮𝗿𝘁 𝟯: 𝗔𝗻 𝗮𝘀𝘆𝗻𝗰 𝘄𝗿𝗮𝗽𝗽𝗲𝗿 𝘁𝗼 𝗰𝗮𝘁𝗰𝗵 𝗿𝗼𝘂𝘁𝗲 𝗲𝗿𝗿𝗼𝗿𝘀
Express doesn't catch errors thrown inside async route handlers unless you explicitly call next(err). Writing try/catch in every route is noisy and forgettable.
This wrapper handles it once:

// 𝘴𝘳𝘤/𝘶𝘵𝘪𝘭𝘴/𝘢𝘴𝘺𝘯𝘤𝘏𝘢𝘯𝘥𝘭𝘦𝘳.𝘵𝘴
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘙𝘦𝘲𝘶𝘦𝘴𝘵, 𝘙𝘦𝘴𝘱𝘰𝘯𝘴𝘦, 𝘕𝘦𝘹𝘵𝘍𝘶𝘯𝘤𝘵𝘪𝘰𝘯, 𝘙𝘦𝘲𝘶𝘦𝘴𝘵𝘏𝘢𝘯𝘥𝘭𝘦𝘳 } 𝘧𝘳𝘰𝘮 '𝘦𝘹𝘱𝘳𝘦𝘴𝘴';

𝘦𝘹𝘱𝘰𝘳𝘵 𝘧𝘶𝘯𝘤𝘵𝘪𝘰𝘯 𝘢𝘴𝘺𝘯𝘤𝘏𝘢𝘯𝘥𝘭𝘦𝘳(𝘧𝘯: 𝘙𝘦𝘲𝘶𝘦𝘴𝘵𝘏𝘢𝘯𝘥𝘭𝘦𝘳): 𝘙𝘦𝘲𝘶𝘦𝘴𝘵𝘏𝘢𝘯𝘥𝘭𝘦𝘳 {
𝘳𝘦𝘵𝘶𝘳𝘯 (𝘳𝘦𝘲: 𝘙𝘦𝘲𝘶𝘦𝘴𝘵, 𝘳𝘦𝘴: 𝘙𝘦𝘴𝘱𝘰𝘯𝘴𝘦, 𝘯𝘦𝘹𝘵: 𝘕𝘦𝘹𝘵𝘍𝘶𝘯𝘤𝘵𝘪𝘰𝘯) => {
𝘗𝘳𝘰𝘮𝘪𝘴𝘦.𝘳𝘦𝘴𝘰𝘭𝘷𝘦(𝘧𝘯(𝘳𝘦𝘲, 𝘳𝘦𝘴, 𝘯𝘦𝘹𝘵)).𝘤𝘢𝘵𝘤𝘩(𝘯𝘦𝘹𝘵);
};
}

Now routes look like this:

// 𝘴𝘳𝘤/𝘳𝘰𝘶𝘵𝘦𝘴/𝘶𝘴𝘦𝘳𝘴.𝘵𝘴
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘙𝘰𝘶𝘵𝘦𝘳 } 𝘧𝘳𝘰𝘮 '𝘦𝘹𝘱𝘳𝘦𝘴𝘴';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘢𝘴𝘺𝘯𝘤𝘏𝘢𝘯𝘥𝘭𝘦𝘳 } 𝘧𝘳𝘰𝘮 '../𝘶𝘵𝘪𝘭𝘴/𝘢𝘴𝘺𝘯𝘤𝘏𝘢𝘯𝘥𝘭𝘦𝘳';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘕𝘰𝘵𝘍𝘰𝘶𝘯𝘥𝘌𝘳𝘳𝘰𝘳 } 𝘧𝘳𝘰𝘮 '../𝘦𝘳𝘳𝘰𝘳𝘴/𝘈𝘱𝘱𝘌𝘳𝘳𝘰𝘳';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘶𝘴𝘦𝘳𝘚𝘦𝘳𝘷𝘪𝘤𝘦 } 𝘧𝘳𝘰𝘮 '../𝘴𝘦𝘳𝘷𝘪𝘤𝘦𝘴/𝘶𝘴𝘦𝘳𝘚𝘦𝘳𝘷𝘪𝘤𝘦';

𝘤𝘰𝘯𝘴𝘵 𝘳𝘰𝘶𝘵𝘦𝘳 = 𝘙𝘰𝘶𝘵𝘦𝘳();

𝘳𝘰𝘶𝘵𝘦𝘳.𝘨𝘦𝘵('/:𝘪𝘥', 𝘢𝘴𝘺𝘯𝘤𝘏𝘢𝘯𝘥𝘭𝘦𝘳(𝘢𝘴𝘺𝘯𝘤 (𝘳𝘦𝘲, 𝘳𝘦𝘴) => {
𝘤𝘰𝘯𝘴𝘵 𝘶𝘴𝘦𝘳 = 𝘢𝘸𝘢𝘪𝘵 𝘶𝘴𝘦𝘳𝘚𝘦𝘳𝘷𝘪𝘤𝘦.𝘧𝘪𝘯𝘥𝘉𝘺𝘐𝘥(𝘳𝘦𝘲.𝘱𝘢𝘳𝘢𝘮𝘴.𝘪𝘥);

𝘪𝘧 (!𝘶𝘴𝘦𝘳) {
𝘵𝘩𝘳𝘰𝘸 𝘯𝘦𝘸 𝘕𝘰𝘵𝘍𝘰𝘶𝘯𝘥𝘌𝘳𝘳𝘰𝘳('𝘜𝘴𝘦𝘳'); // 𝘧𝘭𝘰𝘸𝘴 𝘴𝘵𝘳𝘢𝘪𝘨𝘩𝘵 𝘵𝘰 𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳
}

𝘳𝘦𝘴.𝘫𝘴𝘰𝘯({ 𝘴𝘵𝘢𝘵𝘶𝘴: '𝘴𝘶𝘤𝘤𝘦𝘴𝘴', 𝘥𝘢𝘵𝘢: 𝘶𝘴𝘦𝘳 });
}));

𝘦𝘹𝘱𝘰𝘳𝘵 𝘥𝘦𝘧𝘢𝘶𝘭𝘵 𝘳𝘰𝘶𝘵𝘦𝘳;

No try/catch in the route. No next(err) calls manually. Any thrown error — expected or not — flows directly to your central error handler.


𝗣𝗮𝗿𝘁 𝟰: 𝗪𝗶𝗿𝗶𝗻𝗴 𝗶𝘁 𝗮𝗹𝗹 𝘁𝗼𝗴𝗲𝘁𝗵𝗲𝗿 𝗶𝗻 𝗘𝘅𝗽𝗿𝗲𝘀𝘀

// 𝘴𝘳𝘤/𝘢𝘱𝘱.𝘵𝘴
𝘪𝘮𝘱𝘰𝘳𝘵 𝘦𝘹𝘱𝘳𝘦𝘴𝘴 𝘧𝘳𝘰𝘮 '𝘦𝘹𝘱𝘳𝘦𝘴𝘴';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘳𝘢𝘯𝘥𝘰𝘮𝘜𝘜𝘐𝘋 } 𝘧𝘳𝘰𝘮 '𝘤𝘳𝘺𝘱𝘵𝘰';
𝘪𝘮𝘱𝘰𝘳𝘵 𝘶𝘴𝘦𝘳𝘙𝘰𝘶𝘵𝘦𝘴 𝘧𝘳𝘰𝘮 './𝘳𝘰𝘶𝘵𝘦𝘴/𝘶𝘴𝘦𝘳𝘴';
𝘪𝘮𝘱𝘰𝘳𝘵 { 𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳 } 𝘧𝘳𝘰𝘮 './𝘮𝘪𝘥𝘥𝘭𝘦𝘸𝘢𝘳𝘦/𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳';

𝘤𝘰𝘯𝘴𝘵 𝘢𝘱𝘱 = 𝘦𝘹𝘱𝘳𝘦𝘴𝘴();

𝘢𝘱𝘱.𝘶𝘴𝘦(𝘦𝘹𝘱𝘳𝘦𝘴𝘴.𝘫𝘴𝘰𝘯());

// 𝘈𝘵𝘵𝘢𝘤𝘩 𝘳𝘦𝘲𝘶𝘦𝘴𝘵 𝘐𝘋 𝘵𝘰 𝘦𝘷𝘦𝘳𝘺 𝘳𝘦𝘲𝘶𝘦𝘴𝘵
𝘢𝘱𝘱.𝘶𝘴𝘦((𝘳𝘦𝘲, 𝘳𝘦𝘴, 𝘯𝘦𝘹𝘵) => {
𝘤𝘰𝘯𝘴𝘵 𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥 = (𝘳𝘦𝘲.𝘩𝘦𝘢𝘥𝘦𝘳𝘴['𝘹-𝘳𝘦𝘲𝘶𝘦𝘴𝘵-𝘪𝘥'] 𝘢𝘴 𝘴𝘵𝘳𝘪𝘯𝘨) || 𝘳𝘢𝘯𝘥𝘰𝘮𝘜𝘜𝘐𝘋();
𝘳𝘦𝘲.𝘩𝘦𝘢𝘥𝘦𝘳𝘴['𝘹-𝘳𝘦𝘲𝘶𝘦𝘴𝘵-𝘪𝘥'] = 𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥;
𝘳𝘦𝘴.𝘴𝘦𝘵𝘏𝘦𝘢𝘥𝘦𝘳('𝘹-𝘳𝘦𝘲𝘶𝘦𝘴𝘵-𝘪𝘥', 𝘳𝘦𝘲𝘶𝘦𝘴𝘵𝘐𝘥);
𝘯𝘦𝘹𝘵();
});

// 𝘙𝘰𝘶𝘵𝘦𝘴
𝘢𝘱𝘱.𝘶𝘴𝘦('/𝘢𝘱𝘪/𝘶𝘴𝘦𝘳𝘴', 𝘶𝘴𝘦𝘳𝘙𝘰𝘶𝘵𝘦𝘴);

// 404 𝘩𝘢𝘯𝘥𝘭𝘦𝘳 — 𝘮𝘶𝘴𝘵 𝘤𝘰𝘮𝘦 𝘢𝘧𝘵𝘦𝘳 𝘢𝘭𝘭 𝘳𝘰𝘶𝘵𝘦𝘴
𝘢𝘱𝘱.𝘶𝘴𝘦((𝘳𝘦𝘲, 𝘳𝘦𝘴, 𝘯𝘦𝘹𝘵) => {
𝘯𝘦𝘹𝘵(𝘯𝘦𝘸 𝘕𝘰𝘵𝘍𝘰𝘶𝘯𝘥𝘌𝘳𝘳𝘰𝘳(𝘙𝘰𝘶𝘵𝘦 ${𝘳𝘦𝘲.𝘮𝘦𝘵𝘩𝘰𝘥} ${𝘳𝘦𝘲.𝘱𝘢𝘵𝘩}));
});

// 𝘊𝘦𝘯𝘵𝘳𝘢𝘭 𝘦𝘳𝘳𝘰𝘳 𝘩𝘢𝘯𝘥𝘭𝘦𝘳 — 𝘮𝘶𝘴𝘵 𝘣𝘦 𝘭𝘢𝘴𝘵, 𝘮𝘶𝘴𝘵 𝘩𝘢𝘷𝘦 4 𝘱𝘢𝘳𝘢𝘮𝘴
𝘢𝘱𝘱.𝘶𝘴𝘦(𝘦𝘳𝘳𝘰𝘳𝘏𝘢𝘯𝘥𝘭𝘦𝘳);

𝘦𝘹𝘱𝘰𝘳𝘵 𝘥𝘦𝘧𝘢𝘶𝘭𝘵 𝘢𝘱𝘱;

The order matters. The errorHandler must be registered last. The 404 handler must come after all your routes. The request ID middleware must come first so every log line has it.


𝗪𝗵𝗮𝘁 𝘁𝗵𝗶𝘀 𝗴𝗶𝘃𝗲𝘀 𝘆𝗼𝘂 𝗶𝗻 𝗽𝗿𝗼𝗱𝘂𝗰𝘁𝗶𝗼𝗻

Every error in your API now has:
• A typed code field clients can handle programmatically (NOT_FOUND, UNAUTHORIZED, VALIDATION_ERROR)

• A clean message safe to show users

• A requestId that ties logs across every layer

• Full stack traces in logs for unexpected failures

• Zero internal detail leaked to the outside world

When something breaks at 2am, you grep the requestId from the client's error response and pull the full picture from your logs in seconds — not hours.

𝑠𝑟𝑐/
├── 𝑒𝑟𝑟𝑜𝑟𝑠/
│ └── 𝐴𝑝𝑝𝐸𝑟𝑟𝑜𝑟.𝑡𝑠 # 𝑡𝑦𝑝𝑒𝑑 𝑒𝑟𝑟𝑜𝑟 𝑐𝑙𝑎𝑠𝑠𝑒𝑠
├── 𝑚𝑖𝑑𝑑𝑙𝑒𝑤𝑎𝑟𝑒/
│ └── 𝑒𝑟𝑟𝑜𝑟𝐻𝑎𝑛𝑑𝑙𝑒𝑟.𝑡𝑠 # 𝑐𝑒𝑛𝑡𝑟𝑎𝑙 𝑒𝑟𝑟𝑜𝑟 ℎ𝑎𝑛𝑑𝑙𝑒𝑟
├── 𝑢𝑡𝑖𝑙𝑠/
│ └── 𝑎𝑠𝑦𝑛𝑐𝐻𝑎𝑛𝑑𝑙𝑒𝑟.𝑡𝑠 # 𝑎𝑠𝑦𝑛𝑐 𝑟𝑜𝑢𝑡𝑒 𝑤𝑟𝑎𝑝𝑝𝑒𝑟
├── 𝑟𝑜𝑢𝑡𝑒𝑠/
│ └── 𝑢𝑠𝑒𝑟𝑠.𝑡𝑠 # 𝑐𝑙𝑒𝑎𝑛 𝑟𝑜𝑢𝑡𝑒𝑠, 𝑛𝑜 𝑡𝑟𝑦/𝑐𝑎𝑡𝑐ℎ
└── 𝑎𝑝𝑝.𝑡𝑠 # 𝑤𝑖𝑟𝑒𝑑 𝑡𝑜𝑔𝑒𝑡ℎ𝑒𝑟


𝗢𝗻𝗲 𝘁𝗵𝗶𝗻𝗴 𝘁𝗼 𝗮𝗱𝗱 𝗻𝗲𝘅𝘁

If you want to go further, add a process-level handler for truly unexpected crashes:

// 𝘴𝘳𝘤/𝘴𝘦𝘳𝘷𝘦𝘳.𝘵𝘴
𝘱𝘳𝘰𝘤𝘦𝘴𝘴.𝘰𝘯('𝘶𝘯𝘤𝘢𝘶𝘨𝘩𝘵𝘌𝘹𝘤𝘦𝘱𝘵𝘪𝘰𝘯', (𝘦𝘳𝘳) => {
𝘭𝘰𝘨𝘨𝘦𝘳.𝘧𝘢𝘵𝘢𝘭({ 𝘦𝘳𝘳 }, '𝘜𝘯𝘤𝘢𝘶𝘨𝘩𝘵 𝘦𝘹𝘤𝘦𝘱𝘵𝘪𝘰𝘯 — 𝘴𝘩𝘶𝘵𝘵𝘪𝘯𝘨 𝘥𝘰𝘸𝘯');
𝘱𝘳𝘰𝘤𝘦𝘴𝘴.𝘦𝘹𝘪𝘵(1);
});

𝘱𝘳𝘰𝘤𝘦𝘴𝘴.𝘰𝘯('𝘶𝘯𝘩𝘢𝘯𝘥𝘭𝘦𝘥𝘙𝘦𝘫𝘦𝘤𝘵𝘪𝘰𝘯', (𝘳𝘦𝘢𝘴𝘰𝘯) => {
𝘭𝘰𝘨𝘨𝘦𝘳.𝘧𝘢𝘵𝘢𝘭({ 𝘳𝘦𝘢𝘴𝘰𝘯 }, '𝘜𝘯𝘩𝘢𝘯𝘥𝘭𝘦𝘥 𝘳𝘦𝘫𝘦𝘤𝘵𝘪𝘰𝘯 — 𝘴𝘩𝘶𝘵𝘵𝘪𝘯𝘨 𝘥𝘰𝘸𝘯');
𝘱𝘳𝘰𝘤𝘦𝘴𝘴.𝘦𝘹𝘪𝘵(1);
});


Fail fast. Fail loud. Let your process manager (PM2, Docker, Kubernetes) restart the service. Never let your app limp along in a broken state.
This pattern takes about 20 minutes to wire up on a new project. It has saved me hours of debugging on every project since.


If you're building a REST API in Express + TypeScript and you don't have something like this in place — start here before you add another feature.


𝐴𝑟𝑒 𝑦𝑜𝑢 𝑢𝑠𝑖𝑛𝑔 𝑎 𝑑𝑖𝑓𝑓𝑒𝑟𝑒𝑛𝑡 𝑒𝑟𝑟𝑜𝑟 ℎ𝑎𝑛𝑑𝑙𝑖𝑛𝑔 𝑝𝑎𝑡𝑡𝑒𝑟𝑛 𝑖𝑛 𝑝𝑟𝑜𝑑𝑢𝑐𝑡𝑖𝑜𝑛? 𝑆𝑜𝑚𝑒𝑡ℎ𝑖𝑛𝑔 𝑦𝑜𝑢'𝑑 𝑎𝑑𝑑 𝑜𝑟 𝑐ℎ𝑎𝑛𝑔𝑒 ℎ𝑒𝑟𝑒? 𝐷𝑟𝑜𝑝 𝑖𝑡 𝑖𝑛 𝑡ℎ𝑒 𝑐𝑜𝑚𝑚𝑒𝑛𝑡𝑠 — 𝐼'𝑚 𝑔𝑒𝑛𝑢𝑖𝑛𝑒𝑙𝑦 𝑐𝑢𝑟𝑖𝑜𝑢𝑠 𝑤ℎ𝑎𝑡 𝑜𝑡ℎ𝑒𝑟𝑠 ℎ𝑎𝑣𝑒 𝑙𝑎𝑛𝑑𝑒𝑑 𝑜𝑛.

Top comments (0)