[{"data":1,"prerenderedAt":3505},["ShallowReactive",2],{"navigation":3,"content-tags:articles":146,"tag-engineering":434},[4],{"title":5,"path":6,"stem":7,"children":8,"page":145},"Articles","\u002Farticles","articles",[9,13,17,21,25,29,33,37,41,45,49,53,57,61,65,69,73,77,81,85,89,93,97,101,105,109,113,117,121,125,129,133,137,141],{"title":10,"path":11,"stem":12},"Building a Claude Code Plugin with Hooks","\u002Farticles\u002F2026-01-04-building-claude-code-plugin-with-hooks","articles\u002F2026-01-04-building-claude-code-plugin-with-hooks",{"title":14,"path":15,"stem":16},"Where should I validate? Everywhere, and for different reasons","\u002Farticles\u002F2026-04-18-validation-across-the-stack","articles\u002F2026-04-18-validation-across-the-stack",{"title":18,"path":19,"stem":20},"Error handling across the stack","\u002Farticles\u002F2026-04-23-error-handling-across-the-stack","articles\u002F2026-04-23-error-handling-across-the-stack",{"title":22,"path":23,"stem":24},"3 Ways to Run NestJS Cron Jobs When Running Multiple Instances","\u002Farticles\u002F3-ways-to-run-nestjs-cron-jobs-when-running-multiple-instances","articles\u002F3-ways-to-run-nestjs-cron-jobs-when-running-multiple-instances",{"title":26,"path":27,"stem":28},"Building a Custom Content Slider (Carousel) in Angular","\u002Farticles\u002Fbuilding-a-custom-content-slider-carousel-in-angular","articles\u002Fbuilding-a-custom-content-slider-carousel-in-angular",{"title":30,"path":31,"stem":32},"Building Jobven: Designing a Developer-Friendly API","\u002Farticles\u002Fbuilding-jobven-designing-a-developer-friendly-api","articles\u002Fbuilding-jobven-designing-a-developer-friendly-api",{"title":34,"path":35,"stem":36},"Building MapleStack: AWS S3 for Data Storage","\u002Farticles\u002Fbuilding-maplestack-aws-s3-for-data-storage","articles\u002Fbuilding-maplestack-aws-s3-for-data-storage",{"title":38,"path":39,"stem":40},"Building MapleStack: Enhancing Email Capabilities with Mailgun","\u002Farticles\u002Fbuilding-maplestack-enhancing-email-capabilities-with-mailgun","articles\u002Fbuilding-maplestack-enhancing-email-capabilities-with-mailgun",{"title":42,"path":43,"stem":44},"Building MapleStack: NestJS for Server-Side Operations","\u002Farticles\u002Fbuilding-maplestack-nestjs-for-server-side-operations","articles\u002Fbuilding-maplestack-nestjs-for-server-side-operations",{"title":46,"path":47,"stem":48},"Building MapleStack: PostgreSQL for Data Storage","\u002Farticles\u002Fbuilding-maplestack-postgresql-for-data-storage","articles\u002Fbuilding-maplestack-postgresql-for-data-storage",{"title":50,"path":51,"stem":52},"Building MapleStack: React for an interactive user interface","\u002Farticles\u002Fbuilding-maplestack-react-for-an-interactive-user-interface","articles\u002Fbuilding-maplestack-react-for-an-interactive-user-interface",{"title":54,"path":55,"stem":56},"Building MapleStack: Securing Connections with Let's Encrypt","\u002Farticles\u002Fbuilding-maplestack-securing-connections-with-lets-encrypt","articles\u002Fbuilding-maplestack-securing-connections-with-lets-encrypt",{"title":58,"path":59,"stem":60},"Building MapleStack: Simplifying User Authorization with AWS Cognito","\u002Farticles\u002Fbuilding-maplestack-simplifying-user-authorization-with-aws-cognito","articles\u002Fbuilding-maplestack-simplifying-user-authorization-with-aws-cognito",{"title":62,"path":63,"stem":64},"Building MapleStack: Tailwind CSS for Streamlined Styling","\u002Farticles\u002Fbuilding-maplestack-tailwind-css-for-streamlined-styling","articles\u002Fbuilding-maplestack-tailwind-css-for-streamlined-styling",{"title":66,"path":67,"stem":68},"Debugging Multiple NestJS Applications in VSCode","\u002Farticles\u002Fdebugging-multiple-nestjs-applications-in-vscode","articles\u002Fdebugging-multiple-nestjs-applications-in-vscode",{"title":70,"path":71,"stem":72},"Enums as arrays in PostgreSQL - Updated 2024","\u002Farticles\u002Fenums-as-arrays-in-postgresql","articles\u002Fenums-as-arrays-in-postgresql",{"title":74,"path":75,"stem":76},"FindChildcare.ca","\u002Farticles\u002Ffindchildcare-ca","articles\u002Ffindchildcare-ca",{"title":78,"path":79,"stem":80},"GraphQL Server - Apollo, KoaJS and Typescript implementation","\u002Farticles\u002Fgraphql-server-apollo-koajs-and-typescript-implementation","articles\u002Fgraphql-server-apollo-koajs-and-typescript-implementation",{"title":82,"path":83,"stem":84},"How I built MapleStack's waitlist with AI: ChatGPT vs Bard","\u002Farticles\u002Fhow-i-built-maplestacks-waitlist-with-ai-chatgpt-vs-bard","articles\u002Fhow-i-built-maplestacks-waitlist-with-ai-chatgpt-vs-bard",{"title":86,"path":87,"stem":88},"How to Integrate Google reCAPTCHA v3 with NestJS in 3 Easy Steps","\u002Farticles\u002Fhow-to-integrate-google-recaptcha-v3-with-nestjs-in-3-easy-steps","articles\u002Fhow-to-integrate-google-recaptcha-v3-with-nestjs-in-3-easy-steps",{"title":90,"path":91,"stem":92},"How to resolve a blacklisted domain","\u002Farticles\u002Fhow-to-resolve-a-blacklisted-domain","articles\u002Fhow-to-resolve-a-blacklisted-domain",{"title":94,"path":95,"stem":96},"Implementing WordPress-like Tags in Nuxt Content","\u002Farticles\u002Fimplementing-wordpress-like-tags-in-nuxt","articles\u002Fimplementing-wordpress-like-tags-in-nuxt",{"title":98,"path":99,"stem":100},"Jobven","\u002Farticles\u002Fjobven","articles\u002Fjobven",{"title":102,"path":103,"stem":104},"KidzLog","\u002Farticles\u002Fkidzlog","articles\u002Fkidzlog",{"title":106,"path":107,"stem":108},"MapleStack","\u002Farticles\u002Fmaplestack","articles\u002Fmaplestack",{"title":110,"path":111,"stem":112},"Migrating from WordPress to Nuxt","\u002Farticles\u002Fmigrating-from-wordpress-to-nuxt","articles\u002Fmigrating-from-wordpress-to-nuxt",{"title":114,"path":115,"stem":116},"My Initial Server Setup Checklist","\u002Farticles\u002Fmy-initial-server-setup-checklist","articles\u002Fmy-initial-server-setup-checklist",{"title":118,"path":119,"stem":120},"Pre-authorized transaction on the Stellar network","\u002Farticles\u002Fpre-authorized-transaction-on-the-stellar-network","articles\u002Fpre-authorized-transaction-on-the-stellar-network",{"title":122,"path":123,"stem":124},"Saza: Open Source mobile and desktop Stellar wallet release.","\u002Farticles\u002Fsaza-open-source-mobile-and-desktop-stellar-wallet-release","articles\u002Fsaza-open-source-mobile-and-desktop-stellar-wallet-release",{"title":126,"path":127,"stem":128},"SpellCheckMySite.com","\u002Farticles\u002Fspellcheckmysite-com","articles\u002Fspellcheckmysite-com",{"title":130,"path":131,"stem":132},"Using Check Constraints in PostgreSQL for Value Validation","\u002Farticles\u002Fusing-check-constraints-in-postgresql-for-value-validation","articles\u002Fusing-check-constraints-in-postgresql-for-value-validation",{"title":134,"path":135,"stem":136},"Using DataLoader in GraphQL","\u002Farticles\u002Fusing-dataloader-in-graphql","articles\u002Fusing-dataloader-in-graphql",{"title":138,"path":139,"stem":140},"Using Typescript in NodeJS development","\u002Farticles\u002Fusing-typescript-in-nodejs-development","articles\u002Fusing-typescript-in-nodejs-development",{"title":142,"path":143,"stem":144},"Welcome!","\u002Farticles\u002Fwelcome","articles\u002Fwelcome",false,[147,158,168,177,185,195,203,212,221,229,237,245,253,261,270,280,288,296,304,312,320,329,337,345,353,361,369,378,386,394,402,410,418,426],{"id":148,"color":149,"description":150,"extension":151,"meta":152,"name":153,"slug":154,"stem":155,"__hash__":156,"count":157},"tags\u002Ftags\u002Fai.yml","purple","Artificial Intelligence and machine learning topics","yml",{},"AI","ai","tags\u002Fai","3Kbwn1GafRMDqK3ErYiBohkqyMbs4y-gmPA5L0htrtI",2,{"id":159,"color":160,"description":161,"extension":151,"meta":162,"name":163,"slug":164,"stem":165,"__hash__":166,"count":167},"tags\u002Ftags\u002Fangular.yml","red","Angular framework tutorials and component development",{},"Angular","angular","tags\u002Fangular","4JdHB81zNrypA0P8D8gi97pj0Ds9E3_IW7YbLvnzs1k",1,{"id":169,"color":170,"description":171,"extension":151,"meta":172,"name":173,"slug":174,"stem":175,"__hash__":176,"count":157},"tags\u002Ftags\u002Fapi.yml","blue","Designing, building and consuming web APIs",{},"API","api","tags\u002Fapi","A0x8MlouPoSEbat0Yj1-_xytY1yBgyVkGIlY-bTzPUM",{"id":178,"color":179,"description":180,"extension":151,"meta":181,"name":5,"slug":7,"stem":182,"__hash__":183,"count":184},"tags\u002Ftags\u002Farticles.yml","gray","General articles and blog posts",{},"tags\u002Farticles","iT5XsTSsj5v9-aLrtgHxDyk_QCYfK8mlea2vVyCdcZM",7,{"id":186,"color":187,"description":188,"extension":151,"meta":189,"name":190,"slug":191,"stem":192,"__hash__":193,"count":194},"tags\u002Ftags\u002Faws.yml","orange","Amazon Web Services cloud platform tutorials and integrations",{},"AWS","aws","tags\u002Faws","i2eOUxfMNR5PbOtUVc7VAbZk5Y7LupicySpVtJY24_A",3,{"id":196,"color":170,"description":197,"extension":151,"meta":198,"name":199,"slug":200,"stem":201,"__hash__":202,"count":167},"tags\u002Ftags\u002Fbard.yml","Google Bard AI assistant",{},"Bard","bard","tags\u002Fbard","uy9j-VgMeapYwgVu98-Df6yqWCXCAwfLn5j8Sb15ZmE",{"id":204,"color":160,"description":205,"extension":151,"meta":206,"name":207,"slug":208,"stem":209,"__hash__":210,"count":211},"tags\u002Ftags\u002Fbuilding-maplestack.yml","Series on building the MapleStack platform",{},"Building MapleStack","building-maplestack","tags\u002Fbuilding-maplestack","hIpKigdKwKbxd8aqva_KMKT-hpxpLDOCnQ9qoRGyjPg",8,{"id":213,"color":214,"description":215,"extension":151,"meta":216,"name":217,"slug":218,"stem":219,"__hash__":220,"count":167},"tags\u002Ftags\u002Fchatgpt.yml","green","OpenAI ChatGPT integration and usage",{},"ChatGPT","chatgpt","tags\u002Fchatgpt","32dptlACBGlEhrLxxih3XBVQeeqayyl3nUn4xrjh6mM",{"id":222,"color":149,"description":223,"extension":151,"meta":224,"name":225,"slug":226,"stem":227,"__hash__":228,"count":167},"tags\u002Ftags\u002Fclaude-code.yml","Building with and extending Anthropic",{},"Claude Code","claude-code","tags\u002Fclaude-code","YxSs-GYpeUzG-p5B9htEndC3aIrB3COkRkKzxd2SoTE",{"id":230,"color":187,"description":231,"extension":151,"meta":232,"name":233,"slug":234,"stem":235,"__hash__":236,"count":167},"tags\u002Ftags\u002Fdataloader.yml","DataLoader utility for batching and caching",{},"DataLoader","dataloader","tags\u002Fdataloader","LmXdDjUElkWFv52Zjjjs9iSl5ho6XzjgngiwhkAnlLk",{"id":238,"color":170,"description":239,"extension":151,"meta":240,"name":241,"slug":242,"stem":243,"__hash__":244,"count":167},"tags\u002Ftags\u002Fdns.yml","Domain Name System configuration and troubleshooting",{},"DNS","dns","tags\u002Fdns","-ouuVgZR4CIiCKyOp51RQoXCKDvhpBV0KMUf1zqLXNc",{"id":246,"color":170,"description":247,"extension":151,"meta":248,"name":249,"slug":250,"stem":251,"__hash__":252,"count":157},"tags\u002Ftags\u002Fengineering.yml","Software engineering practice, craft and technical decision making",{},"Engineering","engineering","tags\u002Fengineering","t3NT39qm9R9NCBT8lwSuG9cxZZZa4Xds-X7AINZw-3o",{"id":254,"color":160,"description":255,"extension":151,"meta":256,"name":257,"slug":258,"stem":259,"__hash__":260,"count":167},"tags\u002Ftags\u002Ferror-handling.yml","Handling, surfacing and recovering from failure across a stack",{},"Error Handling","error-handling","tags\u002Ferror-handling","jxCFis914HceisiNgt920hxUAe4KgCulUZGLzKcNspg",{"id":262,"color":263,"description":264,"extension":151,"meta":265,"name":266,"slug":267,"stem":268,"__hash__":269,"count":157},"tags\u002Ftags\u002Fgraphql.yml","pink","GraphQL API development, schema design, and implementation guides",{},"GraphQL","graphql","tags\u002Fgraphql","1wfWwIg4deU-5JedMVdkw9j9mualhUlJ1MZDgb6V7R0",{"id":271,"color":272,"description":273,"extension":151,"meta":274,"name":275,"slug":276,"stem":277,"__hash__":278,"count":279},"tags\u002Ftags\u002Fjavascript.yml","yellow","JavaScript programming language tutorials and best practices",{},"JavaScript","javascript","tags\u002Fjavascript","qZheXj7oAA3rukxJWCK5SLSe3-_mCwMq0OAi9BtEFRw",6,{"id":281,"color":179,"description":282,"extension":151,"meta":283,"name":284,"slug":285,"stem":286,"__hash__":287,"count":157},"tags\u002Ftags\u002Fkoajs.yml","Koa.js web framework for Node.js",{},"KoaJS","koajs","tags\u002Fkoajs","KVPDsU-wIMo46n6-bdbF-dRkiwlRbz3RJP7K9vSOrmQ",{"id":289,"color":214,"description":290,"extension":151,"meta":291,"name":292,"slug":293,"stem":294,"__hash__":295,"count":167},"tags\u002Ftags\u002Fletsencrypt.yml","Free SSL\u002FTLS certificates with Let's Encrypt",{},"Let's Encrypt","letsencrypt","tags\u002Fletsencrypt","0s8AZbiIKoWGwiCNeWEc_OIo3aDAJlkSB5fjQlgcGNk",{"id":297,"color":179,"description":298,"extension":151,"meta":299,"name":300,"slug":301,"stem":302,"__hash__":303,"count":167},"tags\u002Ftags\u002Flinux.yml","Linux server administration and command-line tools",{},"Linux","linux","tags\u002Flinux","YmvWMX2B2AdXzWfUycxqXt4m9jSgpLE8q7eW6FeiJPk",{"id":305,"color":160,"description":306,"extension":151,"meta":307,"name":106,"slug":308,"stem":309,"__hash__":310,"count":311},"tags\u002Ftags\u002Fmaplestack.yml","Building MapleStack job board platform",{},"maplestack","tags\u002Fmaplestack","v2dZpc6OuxQeK2KTQIC3RYfv4FwNzIAa0VDTzLjWjAA",9,{"id":313,"color":187,"description":314,"extension":151,"meta":315,"name":316,"slug":317,"stem":318,"__hash__":319,"count":167},"tags\u002Ftags\u002Fmigration.yml","Platform and data migration guides",{},"Migration","migration","tags\u002Fmigration","scw_ZTGUersSET1o4l_NAkZ3cDKiplB53LqtFRvE7Yc",{"id":321,"color":160,"description":322,"extension":151,"meta":323,"name":324,"slug":325,"stem":326,"__hash__":327,"count":328},"tags\u002Ftags\u002Fnestjs.yml","Articles about NestJS framework for building efficient, scalable Node.js server-side applications",{},"NestJS","nestjs","tags\u002Fnestjs","GQFi18GrTd6pSmws7rOsyKTbLATtQwywfsZpQwhCsI0",4,{"id":330,"color":214,"description":331,"extension":151,"meta":332,"name":333,"slug":334,"stem":335,"__hash__":336,"count":194},"tags\u002Ftags\u002Fnodejs.yml","Node.js runtime environment and server-side JavaScript",{},"Node.js","nodejs","tags\u002Fnodejs","lusY1Ii4Hd3O6gLdY2NbhHchd3YAT_Q1QOm0BqrfY_4",{"id":338,"color":214,"description":339,"extension":151,"meta":340,"name":341,"slug":342,"stem":343,"__hash__":344,"count":194},"tags\u002Ftags\u002Fnuxt.yml","Nuxt.js framework for Vue.js applications",{},"Nuxt","nuxt","tags\u002Fnuxt","QwQk7HOatKzP3xW3-ie3L7_OJ7O8hbqCT2E8AUtrV2o",{"id":346,"color":187,"description":347,"extension":151,"meta":348,"name":349,"slug":350,"stem":351,"__hash__":352,"count":167},"tags\u002Ftags\u002Fplugins.yml","Extending tools and frameworks through plugin systems",{},"Plugins","plugins","tags\u002Fplugins","gShR9UaVAM-OLItdfQRg89U_mFSX53KgzFge6eQGSaE",{"id":354,"color":170,"description":355,"extension":151,"meta":356,"name":357,"slug":358,"stem":359,"__hash__":360,"count":194},"tags\u002Ftags\u002Fpostgresql.yml","PostgreSQL database tutorials, tips, and best practices",{},"PostgreSQL","postgresql","tags\u002Fpostgresql","o-29A3fv27MHMpunvcXvumOEdfKJJ_Wu6s_Wdej2ynI",{"id":362,"color":149,"description":363,"extension":151,"meta":364,"name":365,"slug":366,"stem":367,"__hash__":368,"count":211},"tags\u002Ftags\u002Fprojects.yml","Articles about personal and professional projects",{},"Projects","projects","tags\u002Fprojects","L834U-TbUh7RZkMdabAGEHgsgGb1hUWpWeIomHD81sE",{"id":370,"color":371,"description":372,"extension":151,"meta":373,"name":374,"slug":375,"stem":376,"__hash__":377,"count":157},"tags\u002Ftags\u002Freact.yml","cyan","React library guides for building user interfaces",{},"React","react","tags\u002Freact","Cl0931Ka3nLB6sYwUevYf3q--ugJfBakfGOEXDpY6Mw",{"id":379,"color":371,"description":380,"extension":151,"meta":381,"name":382,"slug":383,"stem":384,"__hash__":385,"count":167},"tags\u002Ftags\u002Fsaas.yml","Building and running software as a service products",{},"SaaS","saas","tags\u002Fsaas","74u9GtZbJ7IF37YdnFP6JagBgw0PblIZK2mc3r8bbew",{"id":387,"color":170,"description":388,"extension":151,"meta":389,"name":390,"slug":391,"stem":392,"__hash__":393,"count":167},"tags\u002Ftags\u002Fstellar.yml","Stellar blockchain network and cryptocurrency development",{},"Stellar","stellar","tags\u002Fstellar","H71kcSAHbcG_q3Y5cFkgonNN5uH5JxVsXlZjLaotbnk",{"id":395,"color":371,"description":396,"extension":151,"meta":397,"name":398,"slug":399,"stem":400,"__hash__":401,"count":167},"tags\u002Ftags\u002Ftailwindcss.yml","Tailwind CSS utility-first styling framework",{},"Tailwind CSS","tailwindcss","tags\u002Ftailwindcss","_Q7hBntSUWoSWUcYMwmpz1kfCE7RVlSTR9U86k3av_g",{"id":403,"color":170,"description":404,"extension":151,"meta":405,"name":406,"slug":407,"stem":408,"__hash__":409,"count":184},"tags\u002Ftags\u002Ftypescript.yml","TypeScript language features, type safety, and development tips",{},"TypeScript","typescript","tags\u002Ftypescript","zv13aSlv3NhTqdsNRYQ0pKjE5j8rZZk5zv2kD6guG4c",{"id":411,"color":214,"description":412,"extension":151,"meta":413,"name":414,"slug":415,"stem":416,"__hash__":417,"count":167},"tags\u002Ftags\u002Fvalidation.yml","Validating input and data at every layer of an application",{},"Validation","validation","tags\u002Fvalidation","Y2whDaQYOSOUCRjs2TcEedcEvVA8a2b3kq_-xULT3gY",{"id":419,"color":214,"description":420,"extension":151,"meta":421,"name":422,"slug":423,"stem":424,"__hash__":425,"count":167},"tags\u002Ftags\u002Fvps.yml","Virtual Private Server setup and management",{},"VPS","vps","tags\u002Fvps","SOzIPECxOX5g_l0rF3QhJEWwMnOeRe0htUuhWc62PXE",{"id":427,"color":170,"description":428,"extension":151,"meta":429,"name":430,"slug":431,"stem":432,"__hash__":433,"count":157},"tags\u002Ftags\u002Fwordpress.yml","WordPress CMS and related topics",{},"WordPress","wordpress","tags\u002Fwordpress","FiVBFOXCbR45_HKOqwcZAu9dDru5YOemYUcilPwarYQ",{"tag":435,"articles":437,"relatedTags":3496},{"id":246,"color":170,"description":247,"extension":151,"meta":436,"name":249,"slug":250,"stem":251,"__hash__":252},{},[438,2305],{"id":439,"title":18,"author":440,"body":444,"categories":2294,"date":2295,"description":2296,"extension":2297,"head":2298,"image":2299,"meta":2300,"minRead":755,"navigation":636,"ogImage":2298,"path":19,"robots":2298,"schemaOrg":2298,"seo":2301,"sitemap":2302,"stem":20,"tags":2303,"__hash__":2304},"articles\u002Farticles\u002F2026-04-23-error-handling-across-the-stack.md",{"name":441,"avatar":442},"Peter Oliha",{"src":443,"alt":441},"\u002Fassets\u002Fimages\u002Fauthors\u002Fpeter-oliha.jpg",{"type":445,"value":446,"toc":2282},"minimark",[447,459,462,465,470,473,476,479,483,495,503,1128,1139,1142,1145,1149,1156,1163,1170,1176,1288,1294,1300,1303,1307,1310,1313,1736,1739,1744,1747,1754,1757,1760,1838,1841,1845,1851,1854,1857,1860,1864,1867,1870,2188,2191,2197,2200,2203,2213,2220,2224,2227,2230,2237,2240,2243,2247,2250,2253,2272,2275,2278],[448,449,450,451,458],"p",{},"A few days ago I wrote about ",[452,453,457],"a",{"href":454,"rel":455},"https:\u002F\u002Foliha.dev\u002Farticles\u002F2026-04-18-validation-across-the-stack\u002F",[456],"nofollow","validation across the stack",". This is the companion piece.",[448,460,461],{},"Validation tells you what should not happen. Error handling is what you do when it happens anyway.",[448,463,464],{},"Same note as last time on the code: most of my samples are from NestJS services and Nuxt apps, because that is what I build in. The patterns translate.",[466,467,469],"h2",{"id":468},"things-break","Things break",[448,471,472],{},"The question is not whether your app will hit an error. It will. Third-party APIs go down. Users disconnect mid-request. A vendor quietly deprecates a field. Your AI provider rate-limits you on a Tuesday afternoon. Someone on your team ships a regression.",[448,474,475],{},"The real question is how your app behaves when it does. A graceful recovery, an honest error message to the user, a signal to you that something is off. That is a well-built system. A blank screen, a generic \"Something went wrong\", or worse, silent data corruption. That is where trust gets destroyed.",[448,477,478],{},"Error handling is not defensive coding. It is acknowledging that you do not control everything and building for that honestly.",[466,480,482],{"id":481},"classify-first","Classify first",[448,484,485,486,490,491,494],{},"The biggest mistake I see in error handling code is treating every error the same. A ",[487,488,489],"code",{},"catch"," block that logs and moves on, or one that throws a generic ",[487,492,493],{},"InternalServerError",", is a missed opportunity. Different errors want different responses.",[448,496,497,498,502],{},"Here is a small utility from the ",[452,499,98],{"href":500,"rel":501},"https:\u002F\u002Fjobven.com",[456]," scraper that I end up reaching for constantly.",[504,505,510],"pre",{"className":506,"code":507,"language":508,"meta":509,"style":509},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","export function isRateLimitError(error: unknown): boolean {\n  if (!error || typeof error !== 'object') return false\n  const err = error as Record\u003Cstring, unknown>\n\n  if (err.status === 429 || err.code === 429) return true\n\n  const message = String(err.message || '').toLowerCase()\n  return (\n    message.includes('rate limit') ||\n    message.includes('quota exceeded') ||\n    message.includes('too many requests')\n  )\n}\n\nexport function isTransientError(error: unknown): boolean {\n  if (!error || typeof error !== 'object') return false\n  const err = error as Record\u003Cstring, unknown>\n\n  if (err.isTransientError === true) return true\n\n  const status = (err.status || err.code) as number | undefined\n  if (typeof status === 'number' && status >= 500 && status \u003C 600) return true\n\n  if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') return true\n  if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') return true\n\n  return false\n}\n","ts","",[487,511,512,552,599,632,638,680,684,721,729,753,775,796,802,808,813,837,868,893,898,923,928,968,1016,1021,1066,1111,1116,1123],{"__ignoreMap":509},[513,514,516,520,524,528,532,536,539,543,546,549],"span",{"class":515,"line":167},"line",[513,517,519],{"class":518},"s7zQu","export",[513,521,523],{"class":522},"spNyl"," function",[513,525,527],{"class":526},"s2Zo4"," isRateLimitError",[513,529,531],{"class":530},"sMK4o","(",[513,533,535],{"class":534},"sHdIc","error",[513,537,538],{"class":530},":",[513,540,542],{"class":541},"sBMFI"," unknown",[513,544,545],{"class":530},"):",[513,547,548],{"class":541}," boolean",[513,550,551],{"class":530}," {\n",[513,553,554,557,561,564,567,570,573,576,579,582,586,589,592,595],{"class":515,"line":157},[513,555,556],{"class":518},"  if",[513,558,560],{"class":559},"swJcz"," (",[513,562,563],{"class":530},"!",[513,565,535],{"class":566},"sTEyZ",[513,568,569],{"class":530}," ||",[513,571,572],{"class":530}," typeof",[513,574,575],{"class":566}," error",[513,577,578],{"class":530}," !==",[513,580,581],{"class":530}," '",[513,583,585],{"class":584},"sfazB","object",[513,587,588],{"class":530},"'",[513,590,591],{"class":559},") ",[513,593,594],{"class":518},"return",[513,596,598],{"class":597},"sfNiH"," false\n",[513,600,601,604,607,610,612,615,618,621,624,627,629],{"class":515,"line":194},[513,602,603],{"class":522},"  const",[513,605,606],{"class":566}," err",[513,608,609],{"class":530}," =",[513,611,575],{"class":566},[513,613,614],{"class":518}," as",[513,616,617],{"class":541}," Record",[513,619,620],{"class":530},"\u003C",[513,622,623],{"class":541},"string",[513,625,626],{"class":530},",",[513,628,542],{"class":541},[513,630,631],{"class":530},">\n",[513,633,634],{"class":515,"line":328},[513,635,637],{"emptyLinePlaceholder":636},true,"\n",[513,639,641,643,645,648,651,654,657,661,663,665,667,669,671,673,675,677],{"class":515,"line":640},5,[513,642,556],{"class":518},[513,644,560],{"class":559},[513,646,647],{"class":566},"err",[513,649,650],{"class":530},".",[513,652,653],{"class":566},"status",[513,655,656],{"class":530}," ===",[513,658,660],{"class":659},"sbssI"," 429",[513,662,569],{"class":530},[513,664,606],{"class":566},[513,666,650],{"class":530},[513,668,487],{"class":566},[513,670,656],{"class":530},[513,672,660],{"class":659},[513,674,591],{"class":559},[513,676,594],{"class":518},[513,678,679],{"class":597}," true\n",[513,681,682],{"class":515,"line":279},[513,683,637],{"emptyLinePlaceholder":636},[513,685,686,688,691,693,696,698,700,702,705,707,710,713,715,718],{"class":515,"line":184},[513,687,603],{"class":522},[513,689,690],{"class":566}," message",[513,692,609],{"class":530},[513,694,695],{"class":526}," String",[513,697,531],{"class":559},[513,699,647],{"class":566},[513,701,650],{"class":530},[513,703,704],{"class":566},"message",[513,706,569],{"class":530},[513,708,709],{"class":530}," ''",[513,711,712],{"class":559},")",[513,714,650],{"class":530},[513,716,717],{"class":526},"toLowerCase",[513,719,720],{"class":559},"()\n",[513,722,723,726],{"class":515,"line":211},[513,724,725],{"class":518},"  return",[513,727,728],{"class":559}," (\n",[513,730,731,734,736,739,741,743,746,748,750],{"class":515,"line":311},[513,732,733],{"class":566},"    message",[513,735,650],{"class":530},[513,737,738],{"class":526},"includes",[513,740,531],{"class":559},[513,742,588],{"class":530},[513,744,745],{"class":584},"rate limit",[513,747,588],{"class":530},[513,749,591],{"class":559},[513,751,752],{"class":530},"||\n",[513,754,756,758,760,762,764,766,769,771,773],{"class":515,"line":755},10,[513,757,733],{"class":566},[513,759,650],{"class":530},[513,761,738],{"class":526},[513,763,531],{"class":559},[513,765,588],{"class":530},[513,767,768],{"class":584},"quota exceeded",[513,770,588],{"class":530},[513,772,591],{"class":559},[513,774,752],{"class":530},[513,776,778,780,782,784,786,788,791,793],{"class":515,"line":777},11,[513,779,733],{"class":566},[513,781,650],{"class":530},[513,783,738],{"class":526},[513,785,531],{"class":559},[513,787,588],{"class":530},[513,789,790],{"class":584},"too many requests",[513,792,588],{"class":530},[513,794,795],{"class":559},")\n",[513,797,799],{"class":515,"line":798},12,[513,800,801],{"class":559},"  )\n",[513,803,805],{"class":515,"line":804},13,[513,806,807],{"class":530},"}\n",[513,809,811],{"class":515,"line":810},14,[513,812,637],{"emptyLinePlaceholder":636},[513,814,816,818,820,823,825,827,829,831,833,835],{"class":515,"line":815},15,[513,817,519],{"class":518},[513,819,523],{"class":522},[513,821,822],{"class":526}," isTransientError",[513,824,531],{"class":530},[513,826,535],{"class":534},[513,828,538],{"class":530},[513,830,542],{"class":541},[513,832,545],{"class":530},[513,834,548],{"class":541},[513,836,551],{"class":530},[513,838,840,842,844,846,848,850,852,854,856,858,860,862,864,866],{"class":515,"line":839},16,[513,841,556],{"class":518},[513,843,560],{"class":559},[513,845,563],{"class":530},[513,847,535],{"class":566},[513,849,569],{"class":530},[513,851,572],{"class":530},[513,853,575],{"class":566},[513,855,578],{"class":530},[513,857,581],{"class":530},[513,859,585],{"class":584},[513,861,588],{"class":530},[513,863,591],{"class":559},[513,865,594],{"class":518},[513,867,598],{"class":597},[513,869,871,873,875,877,879,881,883,885,887,889,891],{"class":515,"line":870},17,[513,872,603],{"class":522},[513,874,606],{"class":566},[513,876,609],{"class":530},[513,878,575],{"class":566},[513,880,614],{"class":518},[513,882,617],{"class":541},[513,884,620],{"class":530},[513,886,623],{"class":541},[513,888,626],{"class":530},[513,890,542],{"class":541},[513,892,631],{"class":530},[513,894,896],{"class":515,"line":895},18,[513,897,637],{"emptyLinePlaceholder":636},[513,899,901,903,905,907,909,912,914,917,919,921],{"class":515,"line":900},19,[513,902,556],{"class":518},[513,904,560],{"class":559},[513,906,647],{"class":566},[513,908,650],{"class":530},[513,910,911],{"class":566},"isTransientError",[513,913,656],{"class":530},[513,915,916],{"class":597}," true",[513,918,591],{"class":559},[513,920,594],{"class":518},[513,922,679],{"class":597},[513,924,926],{"class":515,"line":925},20,[513,927,637],{"emptyLinePlaceholder":636},[513,929,931,933,936,938,940,942,944,946,948,950,952,954,956,959,962,965],{"class":515,"line":930},21,[513,932,603],{"class":522},[513,934,935],{"class":566}," status",[513,937,609],{"class":530},[513,939,560],{"class":559},[513,941,647],{"class":566},[513,943,650],{"class":530},[513,945,653],{"class":566},[513,947,569],{"class":530},[513,949,606],{"class":566},[513,951,650],{"class":530},[513,953,487],{"class":566},[513,955,591],{"class":559},[513,957,958],{"class":518},"as",[513,960,961],{"class":541}," number",[513,963,964],{"class":530}," |",[513,966,967],{"class":541}," undefined\n",[513,969,971,973,975,978,980,982,984,987,989,992,994,997,1000,1002,1004,1007,1010,1012,1014],{"class":515,"line":970},22,[513,972,556],{"class":518},[513,974,560],{"class":559},[513,976,977],{"class":530},"typeof",[513,979,935],{"class":566},[513,981,656],{"class":530},[513,983,581],{"class":530},[513,985,986],{"class":584},"number",[513,988,588],{"class":530},[513,990,991],{"class":530}," &&",[513,993,935],{"class":566},[513,995,996],{"class":530}," >=",[513,998,999],{"class":659}," 500",[513,1001,991],{"class":530},[513,1003,935],{"class":566},[513,1005,1006],{"class":530}," \u003C",[513,1008,1009],{"class":659}," 600",[513,1011,591],{"class":559},[513,1013,594],{"class":518},[513,1015,679],{"class":597},[513,1017,1019],{"class":515,"line":1018},23,[513,1020,637],{"emptyLinePlaceholder":636},[513,1022,1024,1026,1028,1030,1032,1034,1036,1038,1041,1043,1045,1047,1049,1051,1053,1055,1058,1060,1062,1064],{"class":515,"line":1023},24,[513,1025,556],{"class":518},[513,1027,560],{"class":559},[513,1029,647],{"class":566},[513,1031,650],{"class":530},[513,1033,487],{"class":566},[513,1035,656],{"class":530},[513,1037,581],{"class":530},[513,1039,1040],{"class":584},"ECONNRESET",[513,1042,588],{"class":530},[513,1044,569],{"class":530},[513,1046,606],{"class":566},[513,1048,650],{"class":530},[513,1050,487],{"class":566},[513,1052,656],{"class":530},[513,1054,581],{"class":530},[513,1056,1057],{"class":584},"ETIMEDOUT",[513,1059,588],{"class":530},[513,1061,591],{"class":559},[513,1063,594],{"class":518},[513,1065,679],{"class":597},[513,1067,1069,1071,1073,1075,1077,1079,1081,1083,1086,1088,1090,1092,1094,1096,1098,1100,1103,1105,1107,1109],{"class":515,"line":1068},25,[513,1070,556],{"class":518},[513,1072,560],{"class":559},[513,1074,647],{"class":566},[513,1076,650],{"class":530},[513,1078,487],{"class":566},[513,1080,656],{"class":530},[513,1082,581],{"class":530},[513,1084,1085],{"class":584},"ECONNREFUSED",[513,1087,588],{"class":530},[513,1089,569],{"class":530},[513,1091,606],{"class":566},[513,1093,650],{"class":530},[513,1095,487],{"class":566},[513,1097,656],{"class":530},[513,1099,581],{"class":530},[513,1101,1102],{"class":584},"ENOTFOUND",[513,1104,588],{"class":530},[513,1106,591],{"class":559},[513,1108,594],{"class":518},[513,1110,679],{"class":597},[513,1112,1114],{"class":515,"line":1113},26,[513,1115,637],{"emptyLinePlaceholder":636},[513,1117,1119,1121],{"class":515,"line":1118},27,[513,1120,725],{"class":518},[513,1122,598],{"class":597},[513,1124,1126],{"class":515,"line":1125},28,[513,1127,807],{"class":530},[448,1129,1130,1131,1134,1135,1138],{},"There is also a third function, ",[487,1132,1133],{},"extractRetryAfterMs",", that pulls the ",[487,1136,1137],{},"Retry-After"," header out of the error if the provider sent one.",[448,1140,1141],{},"These three helpers let every consumer in the system respond to errors the same way: rate limits get a long pause and a requeue, transient errors get a short pause and a requeue, permanent errors get logged and dropped. Without them, every retry loop reinvents the same logic and gets it subtly wrong in different places.",[448,1143,1144],{},"The lesson is not \"write these specific functions\". It is \"classify errors before you decide what to do with them\". Rate limit, transient, permanent, user error, data error: whatever categories make sense for your domain, make them explicit, and let the handling follow from the category.",[466,1146,1148],{"id":1147},"backend-errors","Backend errors",[448,1150,1151,1152,1155],{},"On the backend, errors come from every direction. A database call fails. A third-party service returns a 500. A bug throws an unexpected ",[487,1153,1154],{},"TypeError",". Your job is to make sure all of that is caught, logged with enough context to debug, and returned to the client in a form they can act on.",[448,1157,1158,1159,1162],{},"I run a global exception filter in ",[452,1160,98],{"href":500,"rel":1161},[456],"'s API that does four things for every unhandled error.",[448,1164,1165,1169],{},[1166,1167,1168],"strong",{},"It generates a request ID."," Every error gets a UUID that shows up in the log line, in the response body, and in the Discord notification. When a user reports a broken request, they send me the ID and I can pull the exact trace immediately.",[448,1171,1172,1175],{},[1166,1173,1174],{},"It sanitises what goes back to the client."," 4xx errors are safe to show directly to users (validation failures, permissions, not-found). 5xx errors get a generic \"An internal server error occurred. Please try again later or contact support with your request ID.\" The real message and stack trace stay in the server logs where they belong.",[504,1177,1179],{"className":506,"code":1178,"language":508,"meta":509,"style":509},"private getSanitizedMessage(status: number, originalMessage: string): string {\n  if (status >= 400 && status \u003C 500) {\n    return originalMessage\n  }\n\n  if (this.isProduction) {\n    return 'An internal server error occurred. Please try again later or contact support with your request ID.'\n  }\n\n  return originalMessage\n}\n",[487,1180,1181,1200,1225,1233,1238,1242,1258,1270,1274,1278,1284],{"__ignoreMap":509},[513,1182,1183,1186,1189,1192,1194,1197],{"class":515,"line":167},[513,1184,1185],{"class":566},"private ",[513,1187,1188],{"class":526},"getSanitizedMessage",[513,1190,1191],{"class":566},"(status: number",[513,1193,626],{"class":530},[513,1195,1196],{"class":566}," originalMessage: string): string ",[513,1198,1199],{"class":530},"{\n",[513,1201,1202,1204,1206,1208,1210,1213,1215,1217,1219,1221,1223],{"class":515,"line":157},[513,1203,556],{"class":518},[513,1205,560],{"class":559},[513,1207,653],{"class":566},[513,1209,996],{"class":530},[513,1211,1212],{"class":659}," 400",[513,1214,991],{"class":530},[513,1216,935],{"class":566},[513,1218,1006],{"class":530},[513,1220,999],{"class":659},[513,1222,591],{"class":559},[513,1224,1199],{"class":530},[513,1226,1227,1230],{"class":515,"line":194},[513,1228,1229],{"class":518},"    return",[513,1231,1232],{"class":566}," originalMessage\n",[513,1234,1235],{"class":515,"line":328},[513,1236,1237],{"class":530},"  }\n",[513,1239,1240],{"class":515,"line":640},[513,1241,637],{"emptyLinePlaceholder":636},[513,1243,1244,1246,1248,1251,1254,1256],{"class":515,"line":279},[513,1245,556],{"class":518},[513,1247,560],{"class":559},[513,1249,1250],{"class":530},"this.",[513,1252,1253],{"class":566},"isProduction",[513,1255,591],{"class":559},[513,1257,1199],{"class":530},[513,1259,1260,1262,1264,1267],{"class":515,"line":184},[513,1261,1229],{"class":518},[513,1263,581],{"class":530},[513,1265,1266],{"class":584},"An internal server error occurred. Please try again later or contact support with your request ID.",[513,1268,1269],{"class":530},"'\n",[513,1271,1272],{"class":515,"line":211},[513,1273,1237],{"class":530},[513,1275,1276],{"class":515,"line":311},[513,1277,637],{"emptyLinePlaceholder":636},[513,1279,1280,1282],{"class":515,"line":755},[513,1281,725],{"class":518},[513,1283,1232],{"class":566},[513,1285,1286],{"class":515,"line":777},[513,1287,807],{"class":530},[448,1289,1290,1293],{},[1166,1291,1292],{},"It logs with context."," Every error log includes the request ID, method, path, status code, error name, sanitised body, query params, user agent, and IP. Logging the message alone is a waste. You need the shape of the request to reproduce the bug.",[448,1295,1296,1299],{},[1166,1297,1298],{},"It notifies on 5xx."," For server errors, the filter fires off an async Discord notification. Not a blocking call, not a dependency. If Discord is down, the error still gets handled. Async side effects for monitoring are a pattern worth copying.",[448,1301,1302],{},"The pattern is consistent: catch everything, log everything internally, show the user only what they can act on.",[466,1304,1306],{"id":1305},"frontend-errors","Frontend errors",[448,1308,1309],{},"On the frontend, errors are part of the user experience. A generic \"Something went wrong\" toast is a failure of imagination. The user knows something went wrong, that is why they are reading the toast. They want to know what to do next.",[448,1311,1312],{},"Take a login flow. The backend can reject a login for a few different reasons: wrong credentials, account locked, email not verified. They all come back as authentication errors, but they mean very different things to the user. The tempting thing to do is to branch on the error message and route the user accordingly.",[504,1314,1316],{"className":506,"code":1315,"language":508,"meta":509,"style":509},"async function onSubmit(payload: FormSubmitEvent\u003CSchema>) {\n  try {\n    await login({ email: payload.data.email, password: payload.data.password })\n    await navigateTo(APP_PATHS.DASHBOARD.HOME)\n  } catch {\n    const errorMessage = authError.value?.toLowerCase() || ''\n\n    if (errorMessage.includes('not verified')) {\n      toast.add({\n        title: 'Email Not Verified',\n        description: 'Please verify your email before logging in.',\n        color: 'warning'\n      })\n      await navigateTo({\n        path: APP_PATHS.AUTH.VERIFY_EMAIL,\n        query: { email: payload.data.email }\n      })\n      return\n    }\n\n    toast.add({\n      title: 'Login Failed',\n      description: authError.value || 'Please check your credentials and try again.',\n      color: 'error'\n    })\n  }\n}\n",[487,1317,1318,1348,1355,1409,1433,1443,1475,1479,1507,1521,1538,1554,1568,1575,1586,1608,1635,1641,1646,1651,1655,1668,1684,1708,1721,1728,1732],{"__ignoreMap":509},[513,1319,1320,1323,1325,1328,1330,1333,1335,1338,1340,1343,1346],{"class":515,"line":167},[513,1321,1322],{"class":522},"async",[513,1324,523],{"class":522},[513,1326,1327],{"class":526}," onSubmit",[513,1329,531],{"class":530},[513,1331,1332],{"class":534},"payload",[513,1334,538],{"class":530},[513,1336,1337],{"class":541}," FormSubmitEvent",[513,1339,620],{"class":530},[513,1341,1342],{"class":541},"Schema",[513,1344,1345],{"class":530},">)",[513,1347,551],{"class":530},[513,1349,1350,1353],{"class":515,"line":157},[513,1351,1352],{"class":518},"  try",[513,1354,551],{"class":530},[513,1356,1357,1360,1363,1365,1368,1371,1373,1376,1378,1381,1383,1386,1388,1391,1393,1395,1397,1399,1401,1404,1407],{"class":515,"line":194},[513,1358,1359],{"class":518},"    await",[513,1361,1362],{"class":526}," login",[513,1364,531],{"class":559},[513,1366,1367],{"class":530},"{",[513,1369,1370],{"class":559}," email",[513,1372,538],{"class":530},[513,1374,1375],{"class":566}," payload",[513,1377,650],{"class":530},[513,1379,1380],{"class":566},"data",[513,1382,650],{"class":530},[513,1384,1385],{"class":566},"email",[513,1387,626],{"class":530},[513,1389,1390],{"class":559}," password",[513,1392,538],{"class":530},[513,1394,1375],{"class":566},[513,1396,650],{"class":530},[513,1398,1380],{"class":566},[513,1400,650],{"class":530},[513,1402,1403],{"class":566},"password",[513,1405,1406],{"class":530}," }",[513,1408,795],{"class":559},[513,1410,1411,1413,1416,1418,1421,1423,1426,1428,1431],{"class":515,"line":328},[513,1412,1359],{"class":518},[513,1414,1415],{"class":526}," navigateTo",[513,1417,531],{"class":559},[513,1419,1420],{"class":566},"APP_PATHS",[513,1422,650],{"class":530},[513,1424,1425],{"class":566},"DASHBOARD",[513,1427,650],{"class":530},[513,1429,1430],{"class":566},"HOME",[513,1432,795],{"class":559},[513,1434,1435,1438,1441],{"class":515,"line":640},[513,1436,1437],{"class":530},"  }",[513,1439,1440],{"class":518}," catch",[513,1442,551],{"class":530},[513,1444,1445,1448,1451,1453,1456,1458,1461,1464,1466,1469,1472],{"class":515,"line":279},[513,1446,1447],{"class":522},"    const",[513,1449,1450],{"class":566}," errorMessage",[513,1452,609],{"class":530},[513,1454,1455],{"class":566}," authError",[513,1457,650],{"class":530},[513,1459,1460],{"class":566},"value",[513,1462,1463],{"class":530},"?.",[513,1465,717],{"class":526},[513,1467,1468],{"class":559},"() ",[513,1470,1471],{"class":530},"||",[513,1473,1474],{"class":530}," ''\n",[513,1476,1477],{"class":515,"line":184},[513,1478,637],{"emptyLinePlaceholder":636},[513,1480,1481,1484,1486,1489,1491,1493,1495,1497,1500,1502,1505],{"class":515,"line":211},[513,1482,1483],{"class":518},"    if",[513,1485,560],{"class":559},[513,1487,1488],{"class":566},"errorMessage",[513,1490,650],{"class":530},[513,1492,738],{"class":526},[513,1494,531],{"class":559},[513,1496,588],{"class":530},[513,1498,1499],{"class":584},"not verified",[513,1501,588],{"class":530},[513,1503,1504],{"class":559},")) ",[513,1506,1199],{"class":530},[513,1508,1509,1512,1514,1517,1519],{"class":515,"line":311},[513,1510,1511],{"class":566},"      toast",[513,1513,650],{"class":530},[513,1515,1516],{"class":526},"add",[513,1518,531],{"class":559},[513,1520,1199],{"class":530},[513,1522,1523,1526,1528,1530,1533,1535],{"class":515,"line":755},[513,1524,1525],{"class":559},"        title",[513,1527,538],{"class":530},[513,1529,581],{"class":530},[513,1531,1532],{"class":584},"Email Not Verified",[513,1534,588],{"class":530},[513,1536,1537],{"class":530},",\n",[513,1539,1540,1543,1545,1547,1550,1552],{"class":515,"line":777},[513,1541,1542],{"class":559},"        description",[513,1544,538],{"class":530},[513,1546,581],{"class":530},[513,1548,1549],{"class":584},"Please verify your email before logging in.",[513,1551,588],{"class":530},[513,1553,1537],{"class":530},[513,1555,1556,1559,1561,1563,1566],{"class":515,"line":798},[513,1557,1558],{"class":559},"        color",[513,1560,538],{"class":530},[513,1562,581],{"class":530},[513,1564,1565],{"class":584},"warning",[513,1567,1269],{"class":530},[513,1569,1570,1573],{"class":515,"line":804},[513,1571,1572],{"class":530},"      }",[513,1574,795],{"class":559},[513,1576,1577,1580,1582,1584],{"class":515,"line":810},[513,1578,1579],{"class":518},"      await",[513,1581,1415],{"class":526},[513,1583,531],{"class":559},[513,1585,1199],{"class":530},[513,1587,1588,1591,1593,1596,1598,1601,1603,1606],{"class":515,"line":815},[513,1589,1590],{"class":559},"        path",[513,1592,538],{"class":530},[513,1594,1595],{"class":566}," APP_PATHS",[513,1597,650],{"class":530},[513,1599,1600],{"class":566},"AUTH",[513,1602,650],{"class":530},[513,1604,1605],{"class":566},"VERIFY_EMAIL",[513,1607,1537],{"class":530},[513,1609,1610,1613,1615,1618,1620,1622,1624,1626,1628,1630,1632],{"class":515,"line":839},[513,1611,1612],{"class":559},"        query",[513,1614,538],{"class":530},[513,1616,1617],{"class":530}," {",[513,1619,1370],{"class":559},[513,1621,538],{"class":530},[513,1623,1375],{"class":566},[513,1625,650],{"class":530},[513,1627,1380],{"class":566},[513,1629,650],{"class":530},[513,1631,1385],{"class":566},[513,1633,1634],{"class":530}," }\n",[513,1636,1637,1639],{"class":515,"line":870},[513,1638,1572],{"class":530},[513,1640,795],{"class":559},[513,1642,1643],{"class":515,"line":895},[513,1644,1645],{"class":518},"      return\n",[513,1647,1648],{"class":515,"line":900},[513,1649,1650],{"class":530},"    }\n",[513,1652,1653],{"class":515,"line":925},[513,1654,637],{"emptyLinePlaceholder":636},[513,1656,1657,1660,1662,1664,1666],{"class":515,"line":930},[513,1658,1659],{"class":566},"    toast",[513,1661,650],{"class":530},[513,1663,1516],{"class":526},[513,1665,531],{"class":559},[513,1667,1199],{"class":530},[513,1669,1670,1673,1675,1677,1680,1682],{"class":515,"line":970},[513,1671,1672],{"class":559},"      title",[513,1674,538],{"class":530},[513,1676,581],{"class":530},[513,1678,1679],{"class":584},"Login Failed",[513,1681,588],{"class":530},[513,1683,1537],{"class":530},[513,1685,1686,1689,1691,1693,1695,1697,1699,1701,1704,1706],{"class":515,"line":1018},[513,1687,1688],{"class":559},"      description",[513,1690,538],{"class":530},[513,1692,1455],{"class":566},[513,1694,650],{"class":530},[513,1696,1460],{"class":566},[513,1698,569],{"class":530},[513,1700,581],{"class":530},[513,1702,1703],{"class":584},"Please check your credentials and try again.",[513,1705,588],{"class":530},[513,1707,1537],{"class":530},[513,1709,1710,1713,1715,1717,1719],{"class":515,"line":1023},[513,1711,1712],{"class":559},"      color",[513,1714,538],{"class":530},[513,1716,581],{"class":530},[513,1718,535],{"class":584},[513,1720,1269],{"class":530},[513,1722,1723,1726],{"class":515,"line":1068},[513,1724,1725],{"class":530},"    }",[513,1727,795],{"class":559},[513,1729,1730],{"class":515,"line":1113},[513,1731,1237],{"class":530},[513,1733,1734],{"class":515,"line":1118},[513,1735,807],{"class":530},[448,1737,1738],{},"The UX win is obvious. An unverified email could have thrown a red error toast and left the user to figure out what to do. Instead, the page detects the case, shows a warning toast, and navigates to the verification page with the email pre-filled.",[1740,1741,1743],"h3",{"id":1742},"the-enumeration-tradeoff","The enumeration tradeoff",[448,1745,1746],{},"Here is where this specific pattern gets complicated. Login is a security boundary, and differentiating error messages on a security boundary leaks information.",[448,1748,1749,1750,1753],{},"Consider an attacker cycling through a list of email addresses with a wrong password. If \"account does not exist\" and \"wrong password\" return the same generic error, the attacker learns nothing. But if \"email not verified\" is a distinguishable response, the attacker now knows which emails in their list have accounts on your platform. That is called a ",[1166,1751,1752],{},"user enumeration attack",", and it is the kind of thing that turns a leaked email list into a targeted phishing campaign.",[448,1755,1756],{},"The same logic applies to signup (\"that email is already in use\"), password reset (\"no account found\"), and anywhere else you tell the user something specific about the state of an account before they have proven they own it.",[448,1758,1759],{},"On a security boundary, uniform error responses win. Something like:",[504,1761,1763],{"className":506,"code":1762,"language":508,"meta":509,"style":509},"catch {\n  toast.add({\n    title: 'Login Failed',\n    description: 'Invalid email or password. If you recently signed up, please check your inbox for a verification link.',\n    color: 'error'\n  })\n}\n",[487,1764,1765,1771,1784,1799,1815,1828,1834],{"__ignoreMap":509},[513,1766,1767,1769],{"class":515,"line":167},[513,1768,489],{"class":518},[513,1770,551],{"class":530},[513,1772,1773,1776,1778,1780,1782],{"class":515,"line":157},[513,1774,1775],{"class":566},"  toast",[513,1777,650],{"class":530},[513,1779,1516],{"class":526},[513,1781,531],{"class":559},[513,1783,1199],{"class":530},[513,1785,1786,1789,1791,1793,1795,1797],{"class":515,"line":194},[513,1787,1788],{"class":559},"    title",[513,1790,538],{"class":530},[513,1792,581],{"class":530},[513,1794,1679],{"class":584},[513,1796,588],{"class":530},[513,1798,1537],{"class":530},[513,1800,1801,1804,1806,1808,1811,1813],{"class":515,"line":328},[513,1802,1803],{"class":559},"    description",[513,1805,538],{"class":530},[513,1807,581],{"class":530},[513,1809,1810],{"class":584},"Invalid email or password. If you recently signed up, please check your inbox for a verification link.",[513,1812,588],{"class":530},[513,1814,1537],{"class":530},[513,1816,1817,1820,1822,1824,1826],{"class":515,"line":640},[513,1818,1819],{"class":559},"    color",[513,1821,538],{"class":530},[513,1823,581],{"class":530},[513,1825,535],{"class":584},[513,1827,1269],{"class":530},[513,1829,1830,1832],{"class":515,"line":279},[513,1831,1437],{"class":530},[513,1833,795],{"class":559},[513,1835,1836],{"class":515,"line":184},[513,1837,807],{"class":530},[448,1839,1840],{},"Same message for every auth failure. No branching. No routing. The verification flow moves out of the error path entirely: a standalone \"Resend verification email\" page the user can visit whenever they need to, with a uniform response that is safe to send to anyone.",[1740,1842,1844],{"id":1843},"the-principle-still-holds","The principle still holds",[448,1846,1847,1848,650],{},"None of this contradicts the original point. Errors on the frontend deserve thoughtful user paths. The nuance is that ",[1166,1849,1850],{},"which path depends on the boundary you are on",[448,1852,1853],{},"On a security boundary, thoughtful means uniform. Recovery flows live elsewhere, where the user is not trying to prove identity.",[448,1855,1856],{},"Off a security boundary, thoughtful means routing users to the right next step. An upload that fails because the file is too large should say so. A payment that fails because the card was declined should say so. A save that fails because someone else edited the record first should say so, and offer to reload.",[448,1858,1859],{},"Good frontend error handling is not about catching exceptions. It is about knowing which errors deserve different user paths, and knowing when the right path is the same one for everyone.",[466,1861,1863],{"id":1862},"recovery-patterns","Recovery patterns",[448,1865,1866],{},"Some errors are not meant to be shown to anyone. They are meant to be handled: retried, requeued, paused, or failed over.",[448,1868,1869],{},"Queue consumers are where recovery patterns earn their keep. Here is the core of the Jobven enrichment consumer, which calls an AI provider for every scraped job.",[504,1871,1873],{"className":506,"code":1872,"language":508,"meta":509,"style":509},"try {\n  \u002F\u002F ...enrich the job...\n} catch (error) {\n  this.logger.error(`Error enriching job ${msg.jobId}:`, error)\n\n  if (isRateLimitError(error) || (error as any).isRateLimitError) {\n    const retryAfterMs = extractRetryAfterMs(error) || this.DEFAULT_PAUSE_MS\n    await this.pauseEnrichment(retryAfterMs)\n    return new Nack(true)\n  }\n\n  if (isTransientError(error) || (error as any).isTransientError) {\n    await this.pauseEnrichment(this.TRANSIENT_PAUSE_MS)\n    return new Nack(true)\n  }\n\n  this.publishEnrichmentUsage({ \u002F* ...failure analytics... *\u002F })\n  await this.markJobFailed(msg.jobId, error.message)\n  return new Nack(false)\n}\n",[487,1874,1875,1882,1888,1900,1943,1947,1983,2009,2025,2042,2046,2050,2084,2101,2115,2119,2123,2141,2169,2184],{"__ignoreMap":509},[513,1876,1877,1880],{"class":515,"line":167},[513,1878,1879],{"class":518},"try",[513,1881,551],{"class":530},[513,1883,1884],{"class":515,"line":157},[513,1885,1887],{"class":1886},"sHwdD","  \u002F\u002F ...enrich the job...\n",[513,1889,1890,1893,1895,1898],{"class":515,"line":194},[513,1891,1892],{"class":530},"}",[513,1894,1440],{"class":518},[513,1896,1897],{"class":566}," (error) ",[513,1899,1199],{"class":530},[513,1901,1902,1905,1908,1910,1912,1914,1917,1920,1923,1926,1928,1931,1933,1935,1937,1939,1941],{"class":515,"line":328},[513,1903,1904],{"class":530},"  this.",[513,1906,1907],{"class":566},"logger",[513,1909,650],{"class":530},[513,1911,535],{"class":526},[513,1913,531],{"class":559},[513,1915,1916],{"class":530},"`",[513,1918,1919],{"class":584},"Error enriching job ",[513,1921,1922],{"class":530},"${",[513,1924,1925],{"class":566},"msg",[513,1927,650],{"class":530},[513,1929,1930],{"class":566},"jobId",[513,1932,1892],{"class":530},[513,1934,538],{"class":584},[513,1936,1916],{"class":530},[513,1938,626],{"class":530},[513,1940,575],{"class":566},[513,1942,795],{"class":559},[513,1944,1945],{"class":515,"line":640},[513,1946,637],{"emptyLinePlaceholder":636},[513,1948,1949,1951,1953,1956,1958,1960,1962,1964,1966,1968,1970,1973,1975,1977,1979,1981],{"class":515,"line":279},[513,1950,556],{"class":518},[513,1952,560],{"class":559},[513,1954,1955],{"class":526},"isRateLimitError",[513,1957,531],{"class":559},[513,1959,535],{"class":566},[513,1961,591],{"class":559},[513,1963,1471],{"class":530},[513,1965,560],{"class":559},[513,1967,535],{"class":566},[513,1969,614],{"class":518},[513,1971,1972],{"class":541}," any",[513,1974,712],{"class":559},[513,1976,650],{"class":530},[513,1978,1955],{"class":566},[513,1980,591],{"class":559},[513,1982,1199],{"class":530},[513,1984,1985,1987,1990,1992,1995,1997,1999,2001,2003,2006],{"class":515,"line":184},[513,1986,1447],{"class":522},[513,1988,1989],{"class":566}," retryAfterMs",[513,1991,609],{"class":530},[513,1993,1994],{"class":526}," extractRetryAfterMs",[513,1996,531],{"class":559},[513,1998,535],{"class":566},[513,2000,591],{"class":559},[513,2002,1471],{"class":530},[513,2004,2005],{"class":530}," this.",[513,2007,2008],{"class":566},"DEFAULT_PAUSE_MS\n",[513,2010,2011,2013,2015,2018,2020,2023],{"class":515,"line":211},[513,2012,1359],{"class":518},[513,2014,2005],{"class":530},[513,2016,2017],{"class":526},"pauseEnrichment",[513,2019,531],{"class":559},[513,2021,2022],{"class":566},"retryAfterMs",[513,2024,795],{"class":559},[513,2026,2027,2029,2032,2035,2037,2040],{"class":515,"line":311},[513,2028,1229],{"class":518},[513,2030,2031],{"class":530}," new",[513,2033,2034],{"class":526}," Nack",[513,2036,531],{"class":559},[513,2038,2039],{"class":597},"true",[513,2041,795],{"class":559},[513,2043,2044],{"class":515,"line":755},[513,2045,1237],{"class":530},[513,2047,2048],{"class":515,"line":777},[513,2049,637],{"emptyLinePlaceholder":636},[513,2051,2052,2054,2056,2058,2060,2062,2064,2066,2068,2070,2072,2074,2076,2078,2080,2082],{"class":515,"line":798},[513,2053,556],{"class":518},[513,2055,560],{"class":559},[513,2057,911],{"class":526},[513,2059,531],{"class":559},[513,2061,535],{"class":566},[513,2063,591],{"class":559},[513,2065,1471],{"class":530},[513,2067,560],{"class":559},[513,2069,535],{"class":566},[513,2071,614],{"class":518},[513,2073,1972],{"class":541},[513,2075,712],{"class":559},[513,2077,650],{"class":530},[513,2079,911],{"class":566},[513,2081,591],{"class":559},[513,2083,1199],{"class":530},[513,2085,2086,2088,2090,2092,2094,2096,2099],{"class":515,"line":804},[513,2087,1359],{"class":518},[513,2089,2005],{"class":530},[513,2091,2017],{"class":526},[513,2093,531],{"class":559},[513,2095,1250],{"class":530},[513,2097,2098],{"class":566},"TRANSIENT_PAUSE_MS",[513,2100,795],{"class":559},[513,2102,2103,2105,2107,2109,2111,2113],{"class":515,"line":810},[513,2104,1229],{"class":518},[513,2106,2031],{"class":530},[513,2108,2034],{"class":526},[513,2110,531],{"class":559},[513,2112,2039],{"class":597},[513,2114,795],{"class":559},[513,2116,2117],{"class":515,"line":815},[513,2118,1237],{"class":530},[513,2120,2121],{"class":515,"line":839},[513,2122,637],{"emptyLinePlaceholder":636},[513,2124,2125,2127,2130,2132,2134,2137,2139],{"class":515,"line":870},[513,2126,1904],{"class":530},[513,2128,2129],{"class":526},"publishEnrichmentUsage",[513,2131,531],{"class":559},[513,2133,1367],{"class":530},[513,2135,2136],{"class":1886}," \u002F* ...failure analytics... *\u002F",[513,2138,1406],{"class":530},[513,2140,795],{"class":559},[513,2142,2143,2146,2148,2151,2153,2155,2157,2159,2161,2163,2165,2167],{"class":515,"line":895},[513,2144,2145],{"class":518},"  await",[513,2147,2005],{"class":530},[513,2149,2150],{"class":526},"markJobFailed",[513,2152,531],{"class":559},[513,2154,1925],{"class":566},[513,2156,650],{"class":530},[513,2158,1930],{"class":566},[513,2160,626],{"class":530},[513,2162,575],{"class":566},[513,2164,650],{"class":530},[513,2166,704],{"class":566},[513,2168,795],{"class":559},[513,2170,2171,2173,2175,2177,2179,2182],{"class":515,"line":900},[513,2172,725],{"class":518},[513,2174,2031],{"class":530},[513,2176,2034],{"class":526},[513,2178,531],{"class":559},[513,2180,2181],{"class":597},"false",[513,2183,795],{"class":559},[513,2185,2186],{"class":515,"line":925},[513,2187,807],{"class":530},[448,2189,2190],{},"Three categories, three different responses.",[448,2192,2193,2194,2196],{},"Rate limits get a long pause (respecting the ",[487,2195,1137],{}," header if the provider sent one) and the message goes back on the queue. The consumer will pick it back up after the pause, by which point the window has opened again.",[448,2198,2199],{},"Transient errors (5xx, connection resets, timeouts) get a short pause and a requeue. The assumption is that whatever went wrong is probably resolved by now.",[448,2201,2202],{},"Permanent errors (bad data, a schema violation, something the enrichment provider flatly refuses) get logged to the analytics pipeline, marked as failed on the job record, and dropped. No requeue. Retrying a permanent failure is just shouting into the void.",[448,2204,2205,2206,2208,2209,2212],{},"The other detail worth calling out is how the pause works. ",[487,2207,2017],{}," writes a ",[487,2210,2211],{},"pause_until"," key to Valkey (a Redis-compatible store) that all enrichment consumers across all workers check before processing. One consumer hitting a rate limit pauses the whole fleet. That prevents a thundering herd where every worker hammers the same rate-limited endpoint at the same time.",[448,2214,2215,2216,2219],{},"Similar patterns show up elsewhere. Batch enrichment jobs that fail to commit to the database trigger a cancellation on the external batch provider, so you do not get charged for work whose result you cannot save. Scrapers with persistent failures flip a ",[487,2217,2218],{},"markManualReview"," flag and stop retrying. The pattern is always: decide what \"recovery\" means for this error, do it, and make sure the system does not keep repeating the same mistake.",[466,2221,2223],{"id":2222},"frontend-and-backend-together","Frontend and backend together",[448,2225,2226],{},"Error handling is one of those places where the frontend and backend have to speak the same language. If the backend throws a generic 500 with \"An error occurred\", the frontend has nothing to work with. If the backend returns specific, documented error codes or messages, the frontend can actually do something useful.",[448,2228,2229],{},"The login flow from earlier is a deliberate exception. Outside of security boundaries, specific error shapes pay off: the frontend can route users directly to the fix.",[448,2231,2232,2233,2236],{},"A good example is a metered API plan. If a user exceeds their monthly quota, the backend returns a 429 with a structured error code (",[487,2234,2235],{},"quota_exceeded",") and the relevant plan context in the payload. The frontend knows that code, shows a toast with the right tone, and offers a button that opens the upgrade page with the recommended plan preselected. The user's next action is one click away.",[448,2238,2239],{},"That handshake only works because the backend is deliberate about its error shape and the frontend knows to look for it.",[448,2241,2242],{},"You can get fancier with custom error codes, typed error responses, or shared error enums between a BFF and its UI. The principle is the same. Errors are an interface between your layers, and like any interface, they should be designed, not accidental.",[466,2244,2246],{"id":2245},"closing","Closing",[448,2248,2249],{},"Error handling is your app's insurance policy. Things will go wrong. The only question is whether you recover gracefully, give the user something useful, and learn from what happened.",[448,2251,2252],{},"The patterns that matter are simple:",[2254,2255,2256,2260,2263,2266,2269],"ul",{},[2257,2258,2259],"li",{},"Classify before you handle. Rate limit, transient, permanent, user, system: each deserves a different response.",[2257,2261,2262],{},"Catch everything on the backend, log with full context, and send the user only what they can act on.",[2257,2264,2265],{},"On the frontend, treat errors as part of the user experience. Route them somewhere useful, not to a red toast that explains nothing.",[2257,2267,2268],{},"For queue consumers and async work, build pause-and-requeue logic around classification, and make sure the system stops repeating failures.",[2257,2270,2271],{},"Treat errors as an interface between layers. Design them on purpose.",[448,2273,2274],{},"None of this is glamorous work. Error handling is where a lot of the craft of building software actually lives, and it is what separates apps that feel solid from apps that feel brittle. Spend the time.",[2276,2277],"blog-cta",{},[2279,2280,2281],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}",{"title":509,"searchDepth":157,"depth":157,"links":2283},[2284,2285,2286,2287,2291,2292,2293],{"id":468,"depth":157,"text":469},{"id":481,"depth":157,"text":482},{"id":1147,"depth":157,"text":1148},{"id":1305,"depth":157,"text":1306,"children":2288},[2289,2290],{"id":1742,"depth":194,"text":1743},{"id":1843,"depth":194,"text":1844},{"id":1862,"depth":157,"text":1863},{"id":2222,"depth":157,"text":2223},{"id":2245,"depth":157,"text":2246},[7],"2026-04-23","Things break. The question is not whether your app will hit an error, it is how gracefully it recovers. Here is how I think about error handling across the frontend, backend, and queue consumers, with real patterns from the apps I run.","md",null,"\u002Fassets\u002Fimages\u002Fblog\u002Ferror-handling-across-the-stack.webp",{},{"title":18,"description":2296},{"loc":19},[7,258,250,407],"_GMjgXpvYiF5AsryQ8JCfIz0oFQ7kP1-3XKC2v-ARmk",{"id":2306,"title":14,"author":2307,"body":2309,"categories":3487,"date":3488,"description":3489,"extension":2297,"head":2298,"image":3490,"meta":3491,"minRead":755,"navigation":636,"ogImage":2298,"path":15,"robots":2298,"schemaOrg":2298,"seo":3492,"sitemap":3493,"stem":16,"tags":3494,"__hash__":3495},"articles\u002Farticles\u002F2026-04-18-validation-across-the-stack.md",{"name":441,"avatar":2308},{"src":443,"alt":441},{"type":445,"value":2310,"toc":3471},[2311,2314,2317,2320,2323,2327,2330,2333,2336,2340,2344,2347,2359,2536,2550,2557,2561,2564,2575,2830,2836,2840,2843,2846,2866,3257,3260,3304,3307,3310,3313,3317,3320,3327,3330,3333,3370,3373,3377,3380,3383,3387,3390,3393,3397,3400,3403,3407,3425,3428,3432,3435,3445,3452,3454,3457,3460,3463,3466,3468],[448,2312,2313],{},"There are two things every software engineer building products needs to take seriously. Yes there are others, but I want these two at the top of the list.",[448,2315,2316],{},"Validation and error handling.",[448,2318,2319],{},"This article is about the first one. I will write a follow up on error handling soon, because the two play well together and a lot of the patterns pair up nicely.",[448,2321,2322],{},"A quick note on the code. I spend most of my time in the NestJS ecosystem, so most of the samples below use class-validator decorators and a few custom constraints I have built up over the years. The patterns translate to whatever you are using (Zod, Yup, Vuelidate, vee-validate) and the point is not the syntax, it is the thinking.",[466,2324,2326],{"id":2325},"validation-is-boring-until-it-isnt","Validation is boring until it isn't",[448,2328,2329],{},"Every \"weird bug in prod\" story I have ever heard has a missing validator somewhere in the middle of it. A form that accepts an empty string where it should not. A scraper that silently ingests HTML when it expected JSON. A trial that should have ended last Tuesday.",[448,2331,2332],{},"When you build products, they are rarely used the way you imagined. Validation is how you keep the gap between what you designed for and what actually shows up from getting out of hand.",[448,2334,2335],{},"I think of validation as falling into four flavours. They look similar on the surface but serve different purposes, and mixing them up is where I see people get stuck.",[466,2337,2339],{"id":2338},"the-four-flavours","The four flavours",[1740,2341,2343],{"id":2342},"_1-user-input-validation","1. User input validation",[448,2345,2346],{},"This is what most people think of when they hear validation. Forms. API endpoints. Request bodies. You want the data your users send in to match the shape your app expects.",[448,2348,2349,2350,2353,2354,2358],{},"Here is a small piece of the ",[487,2351,2352],{},"CreateParentDto"," from ",[452,2355,102],{"href":2356,"rel":2357},"https:\u002F\u002Fkidzlog.com",[456],". It is the DTO that runs when a new parent is added to a childcare centre.",[504,2360,2362],{"className":506,"code":2361,"language":508,"meta":509,"style":509},"export class CreateParentDto {\n  @IsOptional()\n  @ValidateIf(o => o.email !== undefined && o.email !== null && o.email !== '')\n  @IsEmail()\n  email?: string\n\n  @IsNotEmpty()\n  @IsUUID('4', { each: true, message: 'Invalid child id' })\n  children: string[]\n}\n",[487,2363,2364,2376,2386,2442,2451,2462,2466,2475,2519,2532],{"__ignoreMap":509},[513,2365,2366,2368,2371,2374],{"class":515,"line":167},[513,2367,519],{"class":518},[513,2369,2370],{"class":522}," class",[513,2372,2373],{"class":541}," CreateParentDto",[513,2375,551],{"class":530},[513,2377,2378,2381,2384],{"class":515,"line":157},[513,2379,2380],{"class":530},"  @",[513,2382,2383],{"class":526},"IsOptional",[513,2385,720],{"class":566},[513,2387,2388,2390,2393,2395,2398,2401,2404,2406,2409,2412,2415,2417,2419,2421,2423,2425,2428,2430,2432,2434,2436,2438,2440],{"class":515,"line":194},[513,2389,2380],{"class":530},[513,2391,2392],{"class":526},"ValidateIf",[513,2394,531],{"class":566},[513,2396,2397],{"class":534},"o",[513,2399,2400],{"class":522}," =>",[513,2402,2403],{"class":566}," o",[513,2405,650],{"class":530},[513,2407,2408],{"class":566},"email ",[513,2410,2411],{"class":530},"!==",[513,2413,2414],{"class":530}," undefined",[513,2416,991],{"class":530},[513,2418,2403],{"class":566},[513,2420,650],{"class":530},[513,2422,2408],{"class":566},[513,2424,2411],{"class":530},[513,2426,2427],{"class":530}," null",[513,2429,991],{"class":530},[513,2431,2403],{"class":566},[513,2433,650],{"class":530},[513,2435,2408],{"class":566},[513,2437,2411],{"class":530},[513,2439,709],{"class":530},[513,2441,795],{"class":566},[513,2443,2444,2446,2449],{"class":515,"line":328},[513,2445,2380],{"class":530},[513,2447,2448],{"class":526},"IsEmail",[513,2450,720],{"class":566},[513,2452,2453,2456,2459],{"class":515,"line":640},[513,2454,2455],{"class":559},"  email",[513,2457,2458],{"class":530},"?:",[513,2460,2461],{"class":541}," string\n",[513,2463,2464],{"class":515,"line":279},[513,2465,637],{"emptyLinePlaceholder":636},[513,2467,2468,2470,2473],{"class":515,"line":184},[513,2469,2380],{"class":530},[513,2471,2472],{"class":526},"IsNotEmpty",[513,2474,720],{"class":566},[513,2476,2477,2479,2482,2484,2486,2489,2491,2493,2495,2498,2500,2502,2504,2506,2508,2510,2513,2515,2517],{"class":515,"line":211},[513,2478,2380],{"class":530},[513,2480,2481],{"class":526},"IsUUID",[513,2483,531],{"class":566},[513,2485,588],{"class":530},[513,2487,2488],{"class":584},"4",[513,2490,588],{"class":530},[513,2492,626],{"class":530},[513,2494,1617],{"class":530},[513,2496,2497],{"class":559}," each",[513,2499,538],{"class":530},[513,2501,916],{"class":597},[513,2503,626],{"class":530},[513,2505,690],{"class":559},[513,2507,538],{"class":530},[513,2509,581],{"class":530},[513,2511,2512],{"class":584},"Invalid child id",[513,2514,588],{"class":530},[513,2516,1406],{"class":530},[513,2518,795],{"class":566},[513,2520,2521,2524,2526,2529],{"class":515,"line":311},[513,2522,2523],{"class":559},"  children",[513,2525,538],{"class":530},[513,2527,2528],{"class":541}," string",[513,2530,2531],{"class":566},"[]\n",[513,2533,2534],{"class":515,"line":755},[513,2535,807],{"class":530},[448,2537,2538,2539,2542,2543,2545,2546,2549],{},"Notice the ",[487,2540,2541],{},"@ValidateIf"," on ",[487,2544,1385],{},". Email is optional, but if someone provides one it has to be a real email. Without that guard, class-validator runs ",[487,2547,2548],{},"@IsEmail"," against empty strings and rejects perfectly valid requests where the user simply left the field blank. Small detail, but those are the details that make the difference between forms that feel helpful and forms that feel adversarial.",[448,2551,2552,2553,2556],{},"The ",[487,2554,2555],{},"each: true"," on the children array is another small thing worth flagging. It validates every UUID in the array, not just the first one. If someone sends in five children and the third one is malformed, you want to know about that one specifically.",[1740,2558,2560],{"id":2559},"_2-system-to-system-validation","2. System to system validation",[448,2562,2563],{},"This is validation for data coming from things that are not your users. Webhooks. Scraper output. Third-party APIs. Batch jobs.",[448,2565,2566,2567,2570,2571,2574],{},"Here is a fun one from the ",[452,2568,98],{"href":500,"rel":2569},[456]," scraper. Each company in the system has a URL normalisation config that decides which query parameters to keep when deduplicating job URLs. The config accepts either the wildcard ",[487,2572,2573],{},"\"*\""," (keep everything) or an explicit array of parameter names.",[504,2576,2578],{"className":506,"code":2577,"language":508,"meta":509,"style":509},"@ValidatorConstraint({ name: 'isArrayOrWildcard', async: false })\nexport class IsArrayOrWildcardConstraint\n  implements ValidatorConstraintInterface\n{\n  validate(value: any, _args: ValidationArguments) {\n    if (value === '*') {\n      return true;\n    }\n\n    if (Array.isArray(value)) {\n      return value.every((item) => typeof item === 'string');\n    }\n\n    return false;\n  }\n\n  defaultMessage(_args: ValidationArguments) {\n    return 'allowedQueryParams must be either an array of strings or the wildcard \"*\"';\n  }\n}\n",[487,2579,2580,2618,2627,2635,2639,2666,2687,2697,2701,2705,2727,2767,2771,2775,2783,2787,2791,2809,2822,2826],{"__ignoreMap":509},[513,2581,2582,2585,2588,2590,2592,2595,2597,2599,2602,2604,2606,2609,2611,2614,2616],{"class":515,"line":167},[513,2583,2584],{"class":530},"@",[513,2586,2587],{"class":526},"ValidatorConstraint",[513,2589,531],{"class":566},[513,2591,1367],{"class":530},[513,2593,2594],{"class":559}," name",[513,2596,538],{"class":530},[513,2598,581],{"class":530},[513,2600,2601],{"class":584},"isArrayOrWildcard",[513,2603,588],{"class":530},[513,2605,626],{"class":530},[513,2607,2608],{"class":559}," async",[513,2610,538],{"class":530},[513,2612,2613],{"class":597}," false",[513,2615,1406],{"class":530},[513,2617,795],{"class":566},[513,2619,2620,2622,2624],{"class":515,"line":157},[513,2621,519],{"class":518},[513,2623,2370],{"class":522},[513,2625,2626],{"class":541}," IsArrayOrWildcardConstraint\n",[513,2628,2629,2632],{"class":515,"line":194},[513,2630,2631],{"class":522},"  implements",[513,2633,2634],{"class":541}," ValidatorConstraintInterface\n",[513,2636,2637],{"class":515,"line":328},[513,2638,1199],{"class":530},[513,2640,2641,2644,2646,2648,2650,2652,2654,2657,2659,2662,2664],{"class":515,"line":640},[513,2642,2643],{"class":559},"  validate",[513,2645,531],{"class":530},[513,2647,1460],{"class":534},[513,2649,538],{"class":530},[513,2651,1972],{"class":541},[513,2653,626],{"class":530},[513,2655,2656],{"class":534}," _args",[513,2658,538],{"class":530},[513,2660,2661],{"class":541}," ValidationArguments",[513,2663,712],{"class":530},[513,2665,551],{"class":530},[513,2667,2668,2670,2672,2674,2676,2678,2681,2683,2685],{"class":515,"line":279},[513,2669,1483],{"class":518},[513,2671,560],{"class":559},[513,2673,1460],{"class":566},[513,2675,656],{"class":530},[513,2677,581],{"class":530},[513,2679,2680],{"class":584},"*",[513,2682,588],{"class":530},[513,2684,591],{"class":559},[513,2686,1199],{"class":530},[513,2688,2689,2692,2694],{"class":515,"line":184},[513,2690,2691],{"class":518},"      return",[513,2693,916],{"class":597},[513,2695,2696],{"class":530},";\n",[513,2698,2699],{"class":515,"line":211},[513,2700,1650],{"class":530},[513,2702,2703],{"class":515,"line":311},[513,2704,637],{"emptyLinePlaceholder":636},[513,2706,2707,2709,2711,2714,2716,2719,2721,2723,2725],{"class":515,"line":755},[513,2708,1483],{"class":518},[513,2710,560],{"class":559},[513,2712,2713],{"class":566},"Array",[513,2715,650],{"class":530},[513,2717,2718],{"class":526},"isArray",[513,2720,531],{"class":559},[513,2722,1460],{"class":566},[513,2724,1504],{"class":559},[513,2726,1199],{"class":530},[513,2728,2729,2731,2734,2736,2739,2741,2743,2746,2748,2750,2752,2755,2757,2759,2761,2763,2765],{"class":515,"line":777},[513,2730,2691],{"class":518},[513,2732,2733],{"class":566}," value",[513,2735,650],{"class":530},[513,2737,2738],{"class":526},"every",[513,2740,531],{"class":559},[513,2742,531],{"class":530},[513,2744,2745],{"class":534},"item",[513,2747,712],{"class":530},[513,2749,2400],{"class":522},[513,2751,572],{"class":530},[513,2753,2754],{"class":566}," item",[513,2756,656],{"class":530},[513,2758,581],{"class":530},[513,2760,623],{"class":584},[513,2762,588],{"class":530},[513,2764,712],{"class":559},[513,2766,2696],{"class":530},[513,2768,2769],{"class":515,"line":798},[513,2770,1650],{"class":530},[513,2772,2773],{"class":515,"line":804},[513,2774,637],{"emptyLinePlaceholder":636},[513,2776,2777,2779,2781],{"class":515,"line":810},[513,2778,1229],{"class":518},[513,2780,2613],{"class":597},[513,2782,2696],{"class":530},[513,2784,2785],{"class":515,"line":815},[513,2786,1237],{"class":530},[513,2788,2789],{"class":515,"line":839},[513,2790,637],{"emptyLinePlaceholder":636},[513,2792,2793,2796,2798,2801,2803,2805,2807],{"class":515,"line":870},[513,2794,2795],{"class":559},"  defaultMessage",[513,2797,531],{"class":530},[513,2799,2800],{"class":534},"_args",[513,2802,538],{"class":530},[513,2804,2661],{"class":541},[513,2806,712],{"class":530},[513,2808,551],{"class":530},[513,2810,2811,2813,2815,2818,2820],{"class":515,"line":895},[513,2812,1229],{"class":518},[513,2814,581],{"class":530},[513,2816,2817],{"class":584},"allowedQueryParams must be either an array of strings or the wildcard \"*\"",[513,2819,588],{"class":530},[513,2821,2696],{"class":530},[513,2823,2824],{"class":515,"line":900},[513,2825,1237],{"class":530},[513,2827,2828],{"class":515,"line":925},[513,2829,807],{"class":530},[448,2831,2832,2833,2835],{},"The reason this is interesting is that the usual validator decorators do not compose well to express \"array of strings OR the literal string ",[487,2834,2680],{},"\". Writing a tiny custom constraint gets you exactly the shape you want, with a message that tells future-you (or a teammate) what the rule actually is.",[1740,2837,2839],{"id":2838},"_3-business-logic-validation","3. Business logic validation",[448,2841,2842],{},"This is the one that catches people. It is not \"is the input well formed\" but \"is this action allowed right now, given the state of the system\".",[448,2844,2845],{},"Think permissions. Trial expiration. Quota enforcement. Can this user delete this record? Has this subscription lapsed? Is this employee still active?",[448,2847,2848,2849,2853,2854,2857,2858,2861,2862,2865],{},"A small but good example from ",[452,2850,74],{"href":2851,"rel":2852},"https:\u002F\u002Ffindchildcare.ca",[456]," is a custom decorator called ",[487,2855,2856],{},"@IsBiggerThan",". Listings have an age range: minimum age and maximum age the centre accepts. The rule is that ",[487,2859,2860],{},"maxAge"," has to be greater than or equal to ",[487,2863,2864],{},"minAge",". That is a business rule, not a formatting rule, and it cannot be expressed in the type system.",[504,2867,2869],{"className":506,"code":2868,"language":508,"meta":509,"style":509},"export function IsBiggerThan(\n  property: string,\n  validationOptions?: IsBiggerThanOptions,\n) {\n  return function (object: object, propertyName: string) {\n    registerDecorator({\n      name: 'isBiggerThan',\n      target: object.constructor,\n      propertyName: propertyName,\n      constraints: [property],\n      options: validationOptions,\n      validator: {\n        validate(value: any, args: ValidationArguments) {\n          const [relatedPropertyName] = args.constraints;\n          const relatedValue = (args.object as any)[relatedPropertyName] || 0;\n\n          const bothNumbers =\n            typeof value === 'number' && typeof relatedValue === 'number';\n\n          if (validationOptions?.allowEqual) {\n            return bothNumbers && value >= relatedValue;\n          }\n\n          return bothNumbers && value > relatedValue;\n        },\n      },\n    });\n  };\n}\n",[487,2870,2871,2883,2894,2906,2912,2940,2949,2965,2981,2992,3010,3022,3031,3057,3080,3117,3121,3131,3162,3166,3185,3202,3207,3211,3229,3234,3239,3247,3252],{"__ignoreMap":509},[513,2872,2873,2875,2877,2880],{"class":515,"line":167},[513,2874,519],{"class":518},[513,2876,523],{"class":522},[513,2878,2879],{"class":526}," IsBiggerThan",[513,2881,2882],{"class":530},"(\n",[513,2884,2885,2888,2890,2892],{"class":515,"line":157},[513,2886,2887],{"class":534},"  property",[513,2889,538],{"class":530},[513,2891,2528],{"class":541},[513,2893,1537],{"class":530},[513,2895,2896,2899,2901,2904],{"class":515,"line":194},[513,2897,2898],{"class":534},"  validationOptions",[513,2900,2458],{"class":530},[513,2902,2903],{"class":541}," IsBiggerThanOptions",[513,2905,1537],{"class":530},[513,2907,2908,2910],{"class":515,"line":328},[513,2909,712],{"class":530},[513,2911,551],{"class":530},[513,2913,2914,2916,2918,2920,2922,2924,2927,2929,2932,2934,2936,2938],{"class":515,"line":640},[513,2915,725],{"class":518},[513,2917,523],{"class":522},[513,2919,560],{"class":530},[513,2921,585],{"class":534},[513,2923,538],{"class":530},[513,2925,2926],{"class":541}," object",[513,2928,626],{"class":530},[513,2930,2931],{"class":534}," propertyName",[513,2933,538],{"class":530},[513,2935,2528],{"class":541},[513,2937,712],{"class":530},[513,2939,551],{"class":530},[513,2941,2942,2945,2947],{"class":515,"line":279},[513,2943,2944],{"class":526},"    registerDecorator",[513,2946,531],{"class":559},[513,2948,1199],{"class":530},[513,2950,2951,2954,2956,2958,2961,2963],{"class":515,"line":184},[513,2952,2953],{"class":559},"      name",[513,2955,538],{"class":530},[513,2957,581],{"class":530},[513,2959,2960],{"class":584},"isBiggerThan",[513,2962,588],{"class":530},[513,2964,1537],{"class":530},[513,2966,2967,2970,2972,2974,2976,2979],{"class":515,"line":211},[513,2968,2969],{"class":559},"      target",[513,2971,538],{"class":530},[513,2973,2926],{"class":566},[513,2975,650],{"class":530},[513,2977,2978],{"class":566},"constructor",[513,2980,1537],{"class":530},[513,2982,2983,2986,2988,2990],{"class":515,"line":311},[513,2984,2985],{"class":559},"      propertyName",[513,2987,538],{"class":530},[513,2989,2931],{"class":566},[513,2991,1537],{"class":530},[513,2993,2994,2997,2999,3002,3005,3008],{"class":515,"line":755},[513,2995,2996],{"class":559},"      constraints",[513,2998,538],{"class":530},[513,3000,3001],{"class":559}," [",[513,3003,3004],{"class":566},"property",[513,3006,3007],{"class":559},"]",[513,3009,1537],{"class":530},[513,3011,3012,3015,3017,3020],{"class":515,"line":777},[513,3013,3014],{"class":559},"      options",[513,3016,538],{"class":530},[513,3018,3019],{"class":566}," validationOptions",[513,3021,1537],{"class":530},[513,3023,3024,3027,3029],{"class":515,"line":798},[513,3025,3026],{"class":559},"      validator",[513,3028,538],{"class":530},[513,3030,551],{"class":530},[513,3032,3033,3036,3038,3040,3042,3044,3046,3049,3051,3053,3055],{"class":515,"line":804},[513,3034,3035],{"class":559},"        validate",[513,3037,531],{"class":530},[513,3039,1460],{"class":534},[513,3041,538],{"class":530},[513,3043,1972],{"class":541},[513,3045,626],{"class":530},[513,3047,3048],{"class":534}," args",[513,3050,538],{"class":530},[513,3052,2661],{"class":541},[513,3054,712],{"class":530},[513,3056,551],{"class":530},[513,3058,3059,3062,3064,3067,3069,3071,3073,3075,3078],{"class":515,"line":810},[513,3060,3061],{"class":522},"          const",[513,3063,3001],{"class":530},[513,3065,3066],{"class":566},"relatedPropertyName",[513,3068,3007],{"class":530},[513,3070,609],{"class":530},[513,3072,3048],{"class":566},[513,3074,650],{"class":530},[513,3076,3077],{"class":566},"constraints",[513,3079,2696],{"class":530},[513,3081,3082,3084,3087,3089,3091,3094,3096,3098,3100,3102,3105,3107,3110,3112,3115],{"class":515,"line":815},[513,3083,3061],{"class":522},[513,3085,3086],{"class":566}," relatedValue",[513,3088,609],{"class":530},[513,3090,560],{"class":559},[513,3092,3093],{"class":566},"args",[513,3095,650],{"class":530},[513,3097,585],{"class":566},[513,3099,614],{"class":518},[513,3101,1972],{"class":541},[513,3103,3104],{"class":559},")[",[513,3106,3066],{"class":566},[513,3108,3109],{"class":559},"] ",[513,3111,1471],{"class":530},[513,3113,3114],{"class":659}," 0",[513,3116,2696],{"class":530},[513,3118,3119],{"class":515,"line":839},[513,3120,637],{"emptyLinePlaceholder":636},[513,3122,3123,3125,3128],{"class":515,"line":870},[513,3124,3061],{"class":522},[513,3126,3127],{"class":566}," bothNumbers",[513,3129,3130],{"class":530}," =\n",[513,3132,3133,3136,3138,3140,3142,3144,3146,3148,3150,3152,3154,3156,3158,3160],{"class":515,"line":895},[513,3134,3135],{"class":530},"            typeof",[513,3137,2733],{"class":566},[513,3139,656],{"class":530},[513,3141,581],{"class":530},[513,3143,986],{"class":584},[513,3145,588],{"class":530},[513,3147,991],{"class":530},[513,3149,572],{"class":530},[513,3151,3086],{"class":566},[513,3153,656],{"class":530},[513,3155,581],{"class":530},[513,3157,986],{"class":584},[513,3159,588],{"class":530},[513,3161,2696],{"class":530},[513,3163,3164],{"class":515,"line":900},[513,3165,637],{"emptyLinePlaceholder":636},[513,3167,3168,3171,3173,3176,3178,3181,3183],{"class":515,"line":925},[513,3169,3170],{"class":518},"          if",[513,3172,560],{"class":559},[513,3174,3175],{"class":566},"validationOptions",[513,3177,1463],{"class":530},[513,3179,3180],{"class":566},"allowEqual",[513,3182,591],{"class":559},[513,3184,1199],{"class":530},[513,3186,3187,3190,3192,3194,3196,3198,3200],{"class":515,"line":930},[513,3188,3189],{"class":518},"            return",[513,3191,3127],{"class":566},[513,3193,991],{"class":530},[513,3195,2733],{"class":566},[513,3197,996],{"class":530},[513,3199,3086],{"class":566},[513,3201,2696],{"class":530},[513,3203,3204],{"class":515,"line":970},[513,3205,3206],{"class":530},"          }\n",[513,3208,3209],{"class":515,"line":1018},[513,3210,637],{"emptyLinePlaceholder":636},[513,3212,3213,3216,3218,3220,3222,3225,3227],{"class":515,"line":1023},[513,3214,3215],{"class":518},"          return",[513,3217,3127],{"class":566},[513,3219,991],{"class":530},[513,3221,2733],{"class":566},[513,3223,3224],{"class":530}," >",[513,3226,3086],{"class":566},[513,3228,2696],{"class":530},[513,3230,3231],{"class":515,"line":1068},[513,3232,3233],{"class":530},"        },\n",[513,3235,3236],{"class":515,"line":1113},[513,3237,3238],{"class":530},"      },\n",[513,3240,3241,3243,3245],{"class":515,"line":1118},[513,3242,1725],{"class":530},[513,3244,712],{"class":559},[513,3246,2696],{"class":530},[513,3248,3249],{"class":515,"line":1125},[513,3250,3251],{"class":530},"  };\n",[513,3253,3255],{"class":515,"line":3254},29,[513,3256,807],{"class":530},[448,3258,3259],{},"Now anywhere I need the constraint, it reads naturally:",[504,3261,3263],{"className":506,"code":3262,"language":508,"meta":509,"style":509},"@IsBiggerThan('minAge', { allowEqual: true })\nmaxAge: number\n",[487,3264,3265,3295],{"__ignoreMap":509},[513,3266,3267,3269,3272,3274,3276,3278,3280,3282,3284,3287,3289,3291,3293],{"class":515,"line":167},[513,3268,2584],{"class":530},[513,3270,3271],{"class":526},"IsBiggerThan",[513,3273,531],{"class":566},[513,3275,588],{"class":530},[513,3277,2864],{"class":584},[513,3279,588],{"class":530},[513,3281,626],{"class":530},[513,3283,1617],{"class":530},[513,3285,3286],{"class":559}," allowEqual",[513,3288,538],{"class":530},[513,3290,916],{"class":597},[513,3292,1406],{"class":530},[513,3294,795],{"class":566},[513,3296,3297,3299,3301],{"class":515,"line":157},[513,3298,2860],{"class":541},[513,3300,538],{"class":530},[513,3302,3303],{"class":566}," number\n",[448,3305,3306],{},"Business rules as reusable decorators is one of those patterns I keep reaching for. It pushes the rule into a named thing you can apply everywhere the rule applies, and you stop writing one-off custom validators in the service layer that drift out of sync across endpoints.",[448,3308,3309],{},"Another flavour of business logic validation is deduplication before insert. KidzLog has a parent service that checks whether a parent with the same email already exists in the same centre before creating a new record. If a match is found, it merges the incoming children into the existing parent instead of creating a duplicate.",[448,3311,3312],{},"This is not validation in the class-validator sense. No decorator will do this for you. But it is validation in the deeper sense: you are confirming that the action is allowed given the current state of the system, and you are picking a sensible path when it is not.",[1740,3314,3316],{"id":3315},"_4-ingestion-drift","4. Ingestion drift",[448,3318,3319],{},"This one deserves its own flavour because it is easy to miss, and scrapers live in the middle of it.",[448,3321,3322,3323,3326],{},"When you ingest data from external sources, the data that validates cleanly today may not validate cleanly in six months. Sites rework their markup. APIs deprecate fields. Vendors silently change response shapes, or start returning ",[487,3324,3325],{},"null"," where they used to return strings. The data you stored three months ago may no longer match the data you are ingesting today.",[448,3328,3329],{},"Scrapers are the clearest example because the HTML you parse is not a contract. It is a page rendered for a human, and the owner of that page owes you nothing. They can change class names, reorder fields, move the job description to a different component, or A\u002FB test two layouts and return a different one on every request. Your selectors silently start matching the wrong thing, and your pipeline happily writes \"Apply now\" into your description column for a week before anyone notices.",[448,3331,3332],{},"The only defence is to treat ingestion boundaries as validation boundaries, even for systems you built. A few things I do in the Jobven scraper:",[2254,3334,3335,3341,3358,3364],{},[2257,3336,3337,3340],{},[1166,3338,3339],{},"Validate the shape, not just the presence."," A successful fetch is not a successful scrape. After parsing, check that the fields you expect are there and look roughly right (job title under N characters, description over N characters, URL matches a domain pattern). Anything that does not pass goes to a review queue, not to the database.",[2257,3342,3343,3346,3347,3350,3351,3354,3355,3357],{},[1166,3344,3345],{},"Count attempts, flag the persistent failures."," Each job has a ",[487,3348,3349],{},"scrapeAttemptCount"," and a ",[487,3352,3353],{},"maxScrapeAttempts",". If the same job fails to validate more times than allowed, it gets a ",[487,3356,2218],{}," flag and stops retrying. You want the system to stop repeating its mistake, and you want a human to see the pile.",[2257,3359,3360,3363],{},[1166,3361,3362],{},"Alert on the rate of drift, not individual failures."," A single failed scrape is noise. A spike in failures for a specific company is usually a signal that their site changed. I publish failure events to a consumer that aggregates by company and pings Discord when the rate jumps.",[2257,3365,3366,3369],{},[1166,3367,3368],{},"Log the raw shape."," Before parsing, log what came in. Not the full content necessarily, but enough to reconstruct what the page looked like when the scrape broke. Drift debugging without the original payload is archaeology.",[448,3371,3372],{},"The same thinking applies to any ingestion boundary. Third-party webhooks, partner APIs, CSV uploads from a client who \"cleaned up\" their spreadsheet before sending it to you. If you did not author the producer, assume the producer will change. Validate on the way in.",[466,3374,3376],{"id":3375},"where-should-i-validate","Where should I validate?",[448,3378,3379],{},"This is the question that comes up most, and I expect it to come up more now that people are prompting their way to full apps with LLMs. I keep getting asked \"should I validate on the frontend or the backend\".",[448,3381,3382],{},"The answer is yes. Both. Also the database. They solve different problems.",[1740,3384,3386],{"id":3385},"frontend-validation-is-for-ux","Frontend validation is for UX",[448,3388,3389],{},"The frontend validates so your users know immediately when something is wrong. A red \"password must be 8 characters\" before they hit submit is worth more than a backend 400 response three seconds later. Frontend validation is about keeping the experience tight.",[448,3391,3392],{},"It is also a layer you cannot trust. The browser is a hostile environment. Anyone can disable your JavaScript, modify the request, or hit the API directly. Frontend validation is for the user, not for your data integrity.",[1740,3394,3396],{"id":3395},"backend-validation-is-for-integrity","Backend validation is for integrity",[448,3398,3399],{},"The backend validates so your data stays coherent. The backend does not trust the frontend. It does not trust other services. It definitely does not trust request bodies that showed up at 3am from an IP in a country you have never shipped to.",[448,3401,3402],{},"Backend validation is where business rules get enforced. The frontend can pretend a trial ended, but the backend is the only place that actually makes that true.",[1740,3404,3406],{"id":3405},"the-database-is-the-last-line-of-defence","The database is the last line of defence",[448,3408,3409,3410,3413,3414,3419,3420,650],{},"And then the database is where you put the constraints you would lose sleep over if they were violated. ",[487,3411,3412],{},"NOT NULL",". Foreign keys. Unique indexes. ",[452,3415,3418],{"href":3416,"rel":3417},"https:\u002F\u002Foliha.dev\u002Farticles\u002Fusing-check-constraints-in-postgresql-for-value-validation\u002F",[456],"Check constraints",". ",[452,3421,3424],{"href":3422,"rel":3423},"https:\u002F\u002Foliha.dev\u002Farticles\u002Fenums-as-arrays-in-postgresql\u002F",[456],"Enums",[448,3426,3427],{},"Everything above the database is code, and code has bugs. The database is the one layer that catches everything, even the bugs in your validation. If a column should never be null, tell the database. If a value must come from a specific set, tell the database. Belt and braces.",[466,3429,3431],{"id":3430},"custom-validators-as-named-rules","Custom validators as named rules",[448,3433,3434],{},"If you take one thing from all of this, let it be this: treat custom validators as a first-class way to express business rules.",[448,3436,3437,3438,3440,3441,3444],{},"Most teams default to putting rules in services or controllers. Then the same rule shows up in three places, drifts in two of them, and the bug you ship is the case where the drift matters. A named decorator like ",[487,3439,2856],{}," or ",[487,3442,3443],{},"@IsUniqueEmail"," turns the rule into a single source of truth you can apply anywhere it belongs.",[448,3446,3447,3448,3451],{},"This is not a NestJS thing or a class-validator thing. The same pattern works in Zod with ",[487,3449,3450],{},".refine()",", in Yup with custom tests, in Vuelidate with custom validators. The point is that the rule has a name, lives in one place, and shows up in your code where the rule applies.",[466,3453,2246],{"id":2245},[448,3455,3456],{},"Validation is not about rejecting data. It is about knowing what shape your data is in at every boundary in your system.",[448,3458,3459],{},"Every boundary is different. User input needs one kind of vigilance. System to system traffic needs another. Business rules need a third. And ingestion drift needs the kind of vigilance that does not get written until something has already broken, so it is worth writing early.",[448,3461,3462],{},"The frameworks and libraries do not matter much. What matters is that you treat validation as a real concern, give it names, put it where it belongs, and resist the temptation to let one layer pick up the slack for another. They all have jobs to do.",[448,3464,3465],{},"Next up, I will write about error handling, which is the partner to validation. Validation tells you what should not happen. Error handling tells you what to do when it happens anyway.",[2276,3467],{},[2279,3469,3470],{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":509,"searchDepth":157,"depth":157,"links":3472},[3473,3474,3480,3485,3486],{"id":2325,"depth":157,"text":2326},{"id":2338,"depth":157,"text":2339,"children":3475},[3476,3477,3478,3479],{"id":2342,"depth":194,"text":2343},{"id":2559,"depth":194,"text":2560},{"id":2838,"depth":194,"text":2839},{"id":3315,"depth":194,"text":3316},{"id":3375,"depth":157,"text":3376,"children":3481},[3482,3483,3484],{"id":3385,"depth":194,"text":3386},{"id":3395,"depth":194,"text":3396},{"id":3405,"depth":194,"text":3406},{"id":3430,"depth":157,"text":3431},{"id":2245,"depth":157,"text":2246},[7],"2026-04-18","Validation is one of those topics that feels boring until something goes wrong in production. Here is how I think about validation across the stack, with real examples from the apps I run.","\u002Fassets\u002Fimages\u002Fblog\u002Fvalidation-across-the-stack.webp",{},{"title":14,"description":3489},{"loc":15},[7,415,250,407],"Lfda9AEaKNYZLPnjKnAKo4oziw_vklMlWV0-YRm1aOY",[3497,3499,3501,3503],{"id":178,"color":179,"description":180,"extension":151,"meta":3498,"name":5,"slug":7,"stem":182,"__hash__":183},{},{"id":403,"color":170,"description":404,"extension":151,"meta":3500,"name":406,"slug":407,"stem":408,"__hash__":409},{},{"id":411,"color":214,"description":412,"extension":151,"meta":3502,"name":414,"slug":415,"stem":416,"__hash__":417},{},{"id":254,"color":160,"description":255,"extension":151,"meta":3504,"name":257,"slug":258,"stem":259,"__hash__":260},{},1789240765358]