What I Learned About Return Types During a Code Review
Today I was doing a code review on a state management class I’d been working on. Everything looked fine at first, clean separation of concerns, good type safety, proper encapsulation. But then I noticed something odd about one of my methods:
The function name and return type together communicate intent:
“I will try to handle this user action. If I succeed, you’ll get true. If the conditions aren’t right or the transition is invalid, you’ll get false.”
This is self-documenting code. A developer reading your API immediately understands that this method might not always succeed.
2. Caller Flexibility
Returning a boolean gives the caller options without forcing them to use it.
Scenario A: Simple Usage (Ignore the Result)
stateManager.handleUserAction('EXPAND');// Works fine. Return value is ignored.</em>
Scenario B: Advanced Usage (React to Success/Failure)
constsuccess=stateManager.handleUserAction('EXPAND');if (!success) {// Play error sound</em>playErrorBuzz();// Show a tooltip</em>showTooltip('Action cannot be performed at this time');// Log analytics</em>analytics.track('action_failed',{action:'submit'});}
The beauty here is that you’re not forcing complexity on simple use cases, but you’re enabling advanced use cases when needed.
3. Avoiding undefined Pitfalls
When you don’t specify a return type and use early returns, TypeScript infers the return type as void | undefined. This can lead to subtle bugs:
// ❌ Without explicit boolean returnpublichandleUserAction(action: UserAction) {if (this.state!=='Active') return;// Returns undefined// ... do stuff}// Later in code:if (handleUserAction('EXPAND')) {// This block will NEVER run, even on success!// Because the function returns undefined, which is falsy.}
With an explicit boolean return type:
// ✅ With explicit boolean return</em>publichandleUserAction(action: UserAction): boolean{if (this.state!=='Active') returnfalse;// Explicit false</em>// ... do stuff</em>returntrue;// Explicit success</em>}// Later in code:</em>if (handleUserAction('EXPAND')) {// This works as expected!</em>showSuccessMessage();}
When NOT to Use This Pattern
This pattern is best for commands or actions that might fail. It’s not appropriate for:
❌ Getters: Methods that just retrieve data (getState(), isActive())
❌ Pure Calculations: Functions that always succeed (calculateTotal(), formatDate())
❌ Async Operations: Use Promise<boolean> or Result<T, E> types instead
Summary: The Professional Standard
⚡ Always return a boolean for methods that:
Perform state changes
Execute user actions
Might fail due to preconditions
✅ Benefits:
Self-documenting intent (the “Try Pattern”)
Caller flexibility (simple or advanced usage)
Avoids undefined pitfalls
Consistent, predictable API
☑️ Real-world impact:
Easier debugging (you can log failures)
Better UX (you can show error messages)
Cleaner analytics (you can track invalid actions)
Bottom line: If you’re writing a method that might not always succeed, return a boolean. Your future self (and your teammates) will thank you.
I still remember debugging a Hero Image and caption detection function at 12AM. Several levels of nesting. I sat there, coffee cold, counting indentation levels like some deranged accountant, muttering “which branch am I even in?”
You’ve been there too, haven’t you? You open a function, and your eyes start scanning. One if statement. Then another nested inside. Then another. The actual business logic? Buried somewhere past line 30, five indentation levels deep, like treasure at the bottom of a badly designed dungeon.
You need to debug a null reference error. Simple enough, right? Wrong. First, you have to mentally trace through a pyramid of conditions just to understand when the code even reaches the problematic line. Ten seconds pass. You’re still mapping the control flow in your head. Your cursor is hovering somewhere in the middle, and you’ve lost track of which else belongs to which if.
This is the reality of deeply nested conditionals. The “happy path” the main logic your function exists to perform becomes obscured by layers of defensive checks. During code reviews, you find yourself scrolling, counting braces, trying to remember which condition you’re currently inside.
The cognitive overhead is real. And it compounds with every new edge case you need to handle.
I wish someone had shown me guard clauses five years earlier.
What Is a Guard Clause?
A guard clause is an early exit that prevents invalid states from flowing deeper into a function.
It’s a conditional statement placed at the beginning of a function that checks for exceptional, invalid, or edge-case conditions. When such a condition is detected, the guard clause immediately exits the function typically via return, throw, or continue.
Key Characteristics
Positioned at the top of the function
Handles invalid, exceptional, or edge cases (not the main logic)
Exits early using return, throw, or continue
Inverts the condition compared to traditional nested if statements
Guard clauses are not about adding more conditions. They’re about reorganizing existing conditions to make the code’s intent clearer.
Before vs After: Visual Proof
The difference between nested conditionals and guard clauses is best understood visually.
❌ Without Guard Clauses
functionprocessOrder(order){if (order!==null) {if (order.items.length >0) {if (order.customer!==null) {if (order.customer.isVerified) {// Finally, the actual business logicconsttotal=calculateTotal(order.items);constdiscount=applyDiscount(order.customer);constfinalAmount=total-discount;returncreateInvoice(order,finalAmount);}else{thrownewError('Customer not verified');}}else{thrownewError('Customer is required');}}else{thrownewError('Order must contain items');}}else{thrownewError('Order cannot be null');}}
Problems:
Business logic is buried at indentation level 5
Error messages are in reverse order (most specific first)
Hard to scan you must read the entire function to find the happy path
Adding a new validation requires navigating the nesting structure
✅ With Guard Clauses
functionprocessOrder(order){// Guard clauses: handle invalid states firstif (order===null) {thrownewError('Order cannot be null');}if (order.items.length ===0) {thrownewError('Order must contain items');}if (order.customer===null) {thrownewError('Customer is required');}if (!order.customer.isVerified) {thrownewError('Customer not verified');}// Happy path: the actual business logicconsttotal=calculateTotal(order.items);constdiscount=applyDiscount(order.customer);constfinalAmount=total-discount;returncreateInvoice(order,finalAmount);}
Benefits:
Zero indentation for the main logic
Validations read in logical order (general to specific)
Happy path is immediately visible
Adding new validations is trivial just insert another guard clause
The visual contrast is undeniable. The second version reads like a checklist: verify preconditions, then execute the core logic.
Why Guard Clauses Improve Code
Guard clauses aren’t just a stylistic preference. They provide measurable improvements to code quality.
🔍 Readability: Top-to-Bottom Scanning
Human eyes scan code from top to bottom. Guard clauses align with this natural reading pattern. You see the preconditions first, then the main logic. No mental stack required to track which branch you’re in.
🧠 Cognitive Load: Fewer Branches to Track
Nested conditionals force you to maintain a mental model of “where you are” in the branching tree. Guard clauses eliminate this. Each check is independent. If you pass all guards, you’re in the happy path. Simple.
🧪 Debugging: Fail Fast, Fail Clearly
When something goes wrong, guard clauses make it obvious where the failure occurred. The function exits immediately at the point of failure. No need to trace through nested blocks to find which condition wasn’t met.
🔧 Maintainability: Easy to Add New Checks
Need to add a new validation? With guard clauses, you simply add another check at the top. With nested conditionals, you need to find the right place in the nesting structure and potentially refactor existing code.
📊 Testability: Isolated Conditions
Each guard clause represents a distinct test case. Testing becomes straightforward: provide input that triggers each guard, and verify the expected early exit. With nested conditionals, you often need complex setup to reach deeply nested branches.
When Guard Clauses Are Appropriate
Guard clauses are powerful, but they’re not a universal solution. Understanding when to use them is key to writing mature, maintainable code.
✅ Good Use Cases
Input Validation
functioncreateUser(username,email,password){if (!username||username.trim() ==='') {thrownewError('Username is required');}if (!email||!isValidEmail(email)) {thrownewError('Valid email is required');}if (!password||password.length <8) {thrownewError('Password must be at least 8 characters');}// Proceed with user creation}
Permission Checks
functiondeletePost(post,user){if (!user.isAuthenticated) {thrownewUnauthorizedError('User must be logged in');}if (post.authorId!==user.id&&!user.isAdmin) {thrownewForbiddenError('Insufficient permissions');}// Proceed with deletion}
functioncalculateDiscount(order){if (order.total<100) {return0;// No discount for orders under $100}if (!order.customer.isPremium) {returnorder.total*0.05;// 5% for regular customers}// Premium customer logicreturnorder.total*0.15;}
⚠️ Questionable Use Cases
Extremely Trivial One-Liners
If your entire function is just a simple check and return, a guard clause might be overkill:
In functions with setup/teardown or resource management, early exits can make cleanup logic harder to track:
// ProblematicfunctionprocessFile(filePath){constfile=openFile(filePath);if (!file.isReadable()) {return;// Oops, file not closed}constdata=file.read();file.close();returndata;}// Better: use try-finally or structured approachfunctionprocessFile(filePath){constfile=openFile(filePath);try{if (!file.isReadable()) {thrownewError('File not readable');}returnfile.read();}finally{file.close();}}
Guard Clauses vs Nested Conditionals: A Decision Guide
The choice between guard clauses and nested conditionals comes down to one key distinction:
🚫 If the condition invalidates execution → Guard Clause
When a condition means “we cannot proceed,” use a guard clause:
functionwithdraw(account,amount){if (amount<=0) {thrownewError('Amount must be positive');}if (account.balance<amount) {thrownewError('Insufficient funds');}// Proceed with withdrawalaccount.balance-=amount;}
🔀 If the condition changes behavior → Structured Branching
When a condition determines how to proceed (not whether to proceed), use structured conditionals:
Guard clauses are not about writing “cleaner” code they’re about writing code that aligns with how developers think and read.
By handling exceptional cases first and keeping the happy path visible, guard clauses reduce cognitive load, improve debuggability, and make maintenance straightforward.
They’re not appropriate everywhere. But when you’re validating inputs, checking permissions, or handling edge cases, guard clauses transform tangled nesting into clear, linear logic.
The next time you write a function, ask yourself: “What conditions invalidate this operation?” Handle those first. Then write the logic that matters.
Your future self and your teammates will thank you.