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:
public handleUserAction(action: UserAction) {
if (this.lifecycleState !== 'Active') return;
const nextState = this.transition(this.currentState, action);
if (!nextState) return;
this.currentState = nextState;
this.notify();
}No return type specified. Just… void by default.
I paused. Should this return something? I wasn’t sure, so I asked my AI Assistant. What I learned completely changed how I think about API design.
The Problem with void Returns
Consider this common scenario:
public handleUserAction(action: UserAction): void {
if (this.lifecycleState !== 'Active') return;
const nextState = this.transition(this.currentState, action);
if (!nextState) return;
this.currentState = nextState;
this.notify();
}What’s wrong here?
- Silent Failures: The caller has no idea if the action succeeded or was ignored.
- Implicit
undefined: Early returns give youundefined, which is a “falsy” value but not semantically meaningful. - No Feedback Loop: You can’t conditionally trigger side effects based on whether the action actually happened.
The Solution: The Try Pattern
The Try Pattern is simple: your method “tries” to perform an action and returns true if it succeeded, false if it didn’t.
public handleUserAction(action: UserAction): boolean {
if (this.lifecycleState !== 'Active') return false;
const nextState = this.transition(this.currentState, action);
if (!nextState) return false;
this.currentState = nextState;
this.notify();
return true;
}Why This Matters: Three Key Benefits
1. The Try Pattern: Clear Intent
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 getfalse.”
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)
const success = 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 return
public handleUserAction(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>
public handleUserAction(action: UserAction): boolean {
if (this.state !== 'Active') return false; // Explicit false</em>
// ... do stuff</em>
return true; // 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>orResult<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
undefinedpitfalls - 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.
