Blog
A Boolean Is a Terrible Status
- software-engineering
- php
- typescript
I still catch myself writing functions that return true on success and false on... everything else.
It feels clean. One bit. Easy to check. Then six months later someone asks why the sync failed, and the only answer the code can give is "no."
That is not a status. That is a shrug.
What you actually needed
Most write paths have more than two endings.
Created. Already existed. Rejected by validation. Remote API timed out. Permission denied. Soft-deleted and ignored. Queued for later.
If you smash all of that into false, you throw away the one thing the next function, the UI, and the logs need: what happened.
PHP: say it with a string or an enum
A Laravel action that returns a bool pushes the real story into side effects and log lines you hope someone wrote.
enum SyncResult: string
{
case Created = 'created';
case Updated = 'updated';
case Skipped = 'skipped';
case Failed = 'failed';
}
public function syncCustomer(Customer $customer): SyncResult
{
if ($customer->trashed()) {
return SyncResult::Skipped;
}
try {
$remoteId = $this->crm->upsert($customer);
return $customer->crm_id
? SyncResult::Updated
: SyncResult::Created;
} catch (CrmUnavailableException $e) {
report($e);
return SyncResult::Failed;
}
}Now the caller can branch on meaning, not vibes.
$result = $this->syncCustomer($customer);
if ($result === SyncResult::Failed) {
SyncCustomerJob::dispatch($customer)->delay(now()->addMinutes(5));
}You still keep exceptions for truly unexpected breakage. Expected outcomes get names.
TypeScript: discriminated unions beat ok: boolean
The frontend version of the same bad habit is { ok: true } / { ok: false, error?: string }.
It works until every failure looks identical in the type system.
type SaveOrderResult =
| { status: "saved"; orderId: string }
| { status: "invalid"; fields: string[] }
| { status: "conflict"; existingId: string }
| { status: "unavailable"; retryAfterMs: number };
function handleSave(result: SaveOrderResult): void {
switch (result.status) {
case "saved":
router.push(`/orders/${result.orderId}`);
break;
case "invalid":
setFieldErrors(result.fields);
break;
case "conflict":
setNotice(`Order already exists: ${result.existingId}`);
break;
case "unavailable":
scheduleRetry(result.retryAfterMs);
break;
}
}The compiler makes you handle each case. That is the point. A boolean cannot nag you when you forget the conflict path.
Where booleans still earn their keep
Flags are fine when the question is honestly yes or no.
isActive. hasVerifiedEmail. shouldNotify. Predicate helpers. Guard clauses.
The smell is using a boolean as a stand-in for a workflow outcome. If you need a comment above the return false to explain which false it is, you needed a named result.
What gets better
Retries get safer, because "failed, try again" is not the same as "skipped on purpose."
UI copy gets honest, because you can tell a conflict from an outage.
Logs get shorter and more useful: one field with status=skipped beats three paragraphs of stack archaeology.
Tests get clearer too. Asserting SyncResult::Created reads like a spec. Asserting true reads like a coin flip.
The habit
Before you ship a function that returns bool, ask: if this returns false, do I know why without reading the method body?
If the answer is no, give the outcomes names. An enum in PHP. A discriminated union in TypeScript. A small result object if you need payload alongside the status.
Booleans are great at answering a single question. Status is not a single question. Stop asking one bit to carry a whole story.