Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

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.

← All posts