Every developer has been there. You push a feature to production, confident it works. Three hours later, your phone buzzes. Error spike. Angry users. You trace the bug back to a single undefined you never expected.
This is not a story about skill. It is a story about assumptions.
Defensive programming is the discipline of writing code that survives contact with the real world — unpredictable inputs, missing data, broken APIs, race conditions, and human error. It is not about being paranoid. It is about being honest: the world will not behave the way you expect, so your code should be ready for that.
Let me show you how to build that discipline in JavaScript, step by step.
The Assumption Problem
Most bugs are not logic errors. They are assumption errors.
You assumed the API always returns an array. You assumed the user always fills in that field. You assumed the library function never returns null. You assumed the environment variable was set.
Every one of those assumptions is a landmine.
Here is a common example:
function getUserDisplayName(user) {
return user.profile.displayName.toUpperCase();
}
This looks fine. But what happens when:
-
userisnull(user not logged in) -
user.profileisundefined(profile not loaded yet) -
user.profile.displayNameisnull(user never set a name)
You get TypeError: Cannot read properties of null. And your whole UI breaks.
The fix is not complicated. It just requires you to stop assuming:
function getUserDisplayName(user) {
const name = user?.profile?.displayName;
if (!name || typeof name !== 'string') {
return 'Anonymous';
}
return name.trim().toUpperCase();
}
Now this function is honest about what it can receive, and graceful about what it cannot handle.
Validate at the Edges
Defensive programming is most important at the boundaries of your system — where data comes in from the outside world.
That means: API responses, user input, URL parameters, localStorage, environment variables, third-party libraries.
Rule of thumb: trust nothing that crosses a boundary.
// Bad: trust the API blindly
async function fetchUserOrders(userId) {
const response = await fetch(`/api/users/${userId}/orders`);
const data = await response.json();
return data.orders.map(order => order.id); // crash if orders is undefined
}
// Good: validate before using
async function fetchUserOrders(userId) {
if (!userId || typeof userId !== 'string') {
throw new Error('Invalid userId provided');
}
const response = await fetch(`/api/users/${userId}/orders`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data?.orders)) {
console.warn('Unexpected orders format', data);
return [];
}
return data.orders
.filter(order => order && typeof order.id !== 'undefined')
.map(order => order.id);
}
This version handles network errors, bad status codes, malformed data, and missing fields. It never silently returns wrong data.
Fail Loudly in Development, Gracefully in Production
One of the smartest things you can do is make your code behave differently in development vs production.
In development, you want errors to be loud, obvious, and immediate. In production, you want errors to be caught, logged, and handled without destroying the user experience.
function assertDev(condition, message) {
if (!condition && process.env.NODE_ENV === 'development') {
throw new Error(`Assertion failed: ${message}`);
}
}
function processPayment(amount, currency) {
assertDev(typeof amount === 'number' && amount > 0, 'Amount must be a positive number');
assertDev(typeof currency === 'string' && currency.length === 3, 'Currency must be a 3-letter ISO code');
// ... rest of logic
}
During development, bad inputs throw immediately so you catch mistakes early. In production, the assertion silently skips and your error boundary or fallback handles the result.
Use TypeScript — But Do Not Rely on It Alone
TypeScript is great. It catches a huge class of bugs at compile time. But it is not a silver bullet.
TypeScript types disappear at runtime. An API response is any until you validate it. A JSON parse result is unknown. A library typed as string can return null if the author was sloppy.
// This TypeScript compiles fine and still crashes at runtime
interface User {
name: string;
email: string;
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/user/${id}`);
return res.json(); // TypeScript trusts this. Reality might not.
}
// Better: parse and validate
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
async function getUser(id: string) {
const res = await fetch(`/api/user/${id}`);
const raw = await res.json();
return UserSchema.parse(raw); // throws if shape is wrong
}
Libraries like Zod or Valibot give you runtime validation that matches your TypeScript types. That is the combination you actually want.
Handle the Unhappy Path First
Most developers write the happy path and then bolt on error handling as an afterthought. Flip that.
When you sit down to write a function, ask yourself: what are all the ways this can fail? Write those cases first. Make them explicit.
async function sendEmail(to, subject, body) {
// Handle failure cases first
if (!to || !to.includes('@')) {
return { success: false, error: 'Invalid email address' };
}
if (!subject || subject.trim().length === 0) {
return { success: false, error: 'Subject cannot be empty' };
}
if (!body || body.trim().length === 0) {
return { success: false, error: 'Email body cannot be empty' };
}
// Now write the happy path
try {
await emailService.send({ to, subject, body });
return { success: true };
} catch (err) {
console.error('Email send failed:', err);
return { success: false, error: 'Failed to send email. Please try again.' };
}
}
This pattern — returning structured results instead of throwing everywhere — is borrowed from functional programming and makes your code much easier to reason about.
Make State Transitions Explicit
A huge source of bugs is implicit state — when your app can be in some weird in-between condition that you never designed for.
Instead of tracking state with a pile of booleans:
// Fragile: too many possible combinations
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [data, setData] = useState(null);
Use a single state with explicit values:
// Robust: exactly 4 possible states, all handled
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'success' | 'error'
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// In your fetch logic
setStatus('loading');
try {
const result = await fetchData();
setData(result);
setStatus('success');
} catch (err) {
setError(err.message);
setStatus('error');
}
Now your UI can switch on status and you will never hit a state where isLoading is true and hasError is also true simultaneously.
Log What Matters (Not Everything)
Good logging is defensive programming for your future self.
When something goes wrong in production, your logs are all you have. But logs that capture everything are almost as useless as logs that capture nothing.
Log:
- Unexpected inputs that were sanitized
- Errors from external services
- Important state transitions
- Security-relevant events
Do not log:
- Every function call
- Sensitive data (passwords, tokens, PII)
- Expected error conditions (user typed wrong password)
async function loginUser(email, password) {
const user = await db.findUserByEmail(email);
if (!user) {
// Expected case — no need to log
return { success: false, error: 'Invalid credentials' };
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
// Log for security monitoring, but not the password
console.info('Failed login attempt', { email, timestamp: new Date().toISOString() });
return { success: false, error: 'Invalid credentials' };
}
return { success: true, userId: user.id };
}
The Mindset Shift
Defensive programming is ultimately about humility.
You are not the only person who will call your function. The data you receive will not always match what you expect. The environment your code runs in will change. The users will do things you never imagined.
When you write code, you are writing a contract. Defensive programming means you honor that contract even when the other party does not.
Start small. Next time you write a function, ask one question before you ship it: what is the worst input this could receive, and what would happen?
Answer that question in code, not just in your head.
That is the habit that separates junior developers from senior ones — not knowing more frameworks, not writing fancier algorithms. It is the discipline of handling what goes wrong, not just what goes right.
Have a defensive programming pattern that saved you in production? Drop it in the comments — I would love to hear it.
Top comments (0)