Form validation in a Next.js app. Submit with an invalid date, get a clear error message: “Invalid start date.” Worked perfectly in local dev. Worked in the dev environment. In the test environment, the same action showed “Something went wrong. Please try again.”
The code hadn’t changed between environments. The build artifact was identical.
The Original Code
We used ES2022’s Error cause option to distinguish validation errors from unexpected failures:
// Validation
if (!validDateRegex.test(fromDate)) {
return new Error(t('Message.Invalid start date'), { cause: 'validation' });
}
// Error handler
onError: (e: Error | AxiosError) => {
if (e?.cause === 'validation') {
alert(e?.message, { type: 'error', shouldDismiss: true });
return;
}
const statusCode = (e as AxiosError)?.response?.status;
// generic error handling...
}
Clean pattern. The cause property tags errors by category, the handler checks it and shows the appropriate message. TypeScript was happy. ESLint was happy. The feature worked.
The Ghost in the Test Environment
Debugging in test, the error object looked almost right:
console.log(e.message); // "Invalid start date" ✓
console.log(e.cause); // undefined ✗
console.log(e.constructor.name); // "Error"
The message was correct. The cause was gone. The Error constructor had silently accepted the options bag and dropped cause on the floor. Every validation error fell through to the generic “Something went wrong” branch.
After ruling out build differences, polyfill issues, and browser quirks, I found it. The test environment loaded a different version of an internal analytics library – a wrapper over Google Tag Manager for event tracking. That version of the library overrode window.Error with its own implementation to capture uncaught errors. Their replacement constructor forwarded the message argument but never handled the second options parameter where cause lives.
In JavaScript, this is perfectly legal. Any script on the page can do window.Error = function(msg) { ... } and every subsequent new Error() call in the entire application uses the replacement.
The Fix
A custom error class with an explicit cause property, checked via instanceof instead of duck-typing:
export class ValidationError extends Error {
cause: string;
constructor(message: string, cause: string = 'validation') {
super(message);
this.name = 'ValidationError';
this.cause = cause;
}
}
The call sites and the handler both changed:
// Before
return new Error(t('Message.Invalid start date'), { cause: 'validation' });
// After
return new ValidationError(t('Message.Invalid start date'));
// Before
if (e?.cause === 'validation') {
// After
if (e instanceof ValidationError) {
Why does instanceof survive when cause didn’t? Because instanceof checks the prototype chain, which is set at class definition time – when your module loads, before any analytics library runs. The cause property is assigned directly on the instance inside the constructor, bypassing the native Error’s options parameter entirely. The fix is self-contained. It does not depend on window.Error behaving correctly.
The Real Lesson
In Java or C#, java.lang.Error at compile time is java.lang.Error at runtime. The type system is a runtime guarantee enforced by the VM’s class loader. You cannot replace it.
In JavaScript, there is no such guarantee. window.Error, Array.prototype.map, JSON.parse, Promise.resolve – all of them can be overridden by any script sharing your global scope:
window.Error = function(msg) { return { message: msg }; };
Array.prototype.map = function() { return []; };
JSON.parse = function() { return null; };
TypeScript cannot protect you here. tsc type-checks against the spec definition of Error, not against whatever window.Error actually is when your code executes. Your types are compile-time assertions. The runtime is a free-for-all.
This is the same mechanism that makes polyfills and Zone.js work – it’s a feature of the language. It’s also why Sentry’s JavaScript SDK has documented issues with its error instrumentation breaking application behavior. Any library that wraps native constructors for instrumentation can introduce the same class of bug.
Key Takeaways
Shared libraries run in your global scope and can mutate native constructors. Analytics wrappers, error-tracking tools, A/B testing scripts – they’re not inert. They execute code that can silently change how
Error,Promise, orfetchbehaves.TypeScript cannot protect against runtime monkey-patching. It type-checks against spec definitions, not against what actually exists at runtime. Compile-time safety is not runtime safety.
Use
instanceofwith custom error classes over duck-typing on properties. AValidationErrorchecked viainstanceofdepends on the prototype chain set at module load time. Duck-typing one?.causedepends on the constructor forwarding parameters correctly – which a replaced constructor may not do.When code works in dev but breaks in test, look at what else is running on the page. Different analytics versions, different library configurations, different feature flags – these are not inert configuration. They execute code in your global scope.