Le Quang Lam
← All writing

Avoid magic strings and numbers

August 29, 2026·7 min read·software

I shipped a multi-step application form. It worked fine in dev, ready to merge into main — until review turned up two comments: what was the number in that condition supposed to mean, and where did that status string come from? Neither was a bug report. He wasn't telling me the code was wrong, he was telling me it couldn't be read.

A programming anti-pattern

Here is roughly what he was looking at:

function ApplicationForm() {
  const [step, setStep] = useState(1)

  const handleNext = () => {
    if (step === 3) submitDocuments()
    setStep(step + 1)
  }

  return (
    <div className="flex flex-col gap-6">
      {step === 1 && <PersonalInfo />}
      {step === 2 && <EmploymentInfo />}
      {step === 3 && <DocumentUpload />}
      {step === 4 && <Review />}
      <button onClick={handleNext} disabled={step === 4}>Next</button>
    </div>
  )
}

Plus, a few files away, the line that drew the second comment:

{application.status === "approved" && <DownloadCertificate />}

What it actually costs:

  • Hard to maintain. Reading step === 3 tells you nothing about what 3 means. A reviewer has to ask and the next developer has to spend time working it out.

  • Hard to refactor. Changing one value means editing the same literal in many files. AI-assisted refactoring helps, but you still have to review each edit.

  • Typo-prone. "pending" and "Pending" are different strings, and nothing catches the difference.

  • No type safety. A plain string accepts any value, so an invalid status still compiles.

Step numbers: a value carrying two jobs

Back to the form. The obvious first fix is to name the numbers:

const STEP_PERSONAL = 1
const STEP_EMPLOYMENT = 2
const STEP_DOCUMENTS = 3
const STEP_REVIEW = 4

This reads better, and it is still broken in exactly the same way. Add the address step in the middle and you are back to renumbering every constant by hand. Naming the number didn't help, because the number was never the problem.

Once the two jobs are separate, the fix is obvious: order lives in one ordered list; identity is a name.

// features/application/steps.ts
export const STEPS = [
  { id: "personal", label: "Personal info" },
  { id: "employment", label: "Employment" },
  { id: "documents", label: "Documents" },
  { id: "review", label: "Review" },
] as const

export type StepId = (typeof STEPS)[number]["id"] 
// "personal" | "employment" | "documents" | "review"

export const stepIndex = (id: StepId) => STEPS.findIndex((s) => s.id === id)
// returns 0, 1, 2, or 3

as const is doing real work here. Without it, TypeScript widens every id to string and you get nothing. With it, StepId is a union of the four literal names, derived from the array — so the list stays the single source of truth and the type can never fall out of sync with it.

import { STEPS, stepIndex, type StepId } from "@/features/application/steps"

function ApplicationForm() {
  const [stepId, setStepId] = useState<StepId>(STEPS[0].id)

  const index = stepIndex(stepId)
  const isLast = index === STEPS.length - 1

  const handleNext = () => {
    if (stepId === "documents") submitDocuments()
    if (!isLast) setStepId(STEPS[index + 1].id)
  }

  return (
    <div className="flex flex-col gap-6">
      {stepId === "personal" && <PersonalInfo />}
      {stepId === "employment" && <EmploymentInfo />}
      {stepId === "documents" && <DocumentUpload />}
      {stepId === "review" && <Review />}
      <button onClick={handleNext} disabled={isLast}>Next</button>
    </div>
  )
}

The initial value is worth a beat. useState<StepId>("personal") would be type-safe — but "start at the beginning" is a statement about order, so writing it as a name means adding a step above personal quietly starts the form on the second screen. Order questions go to STEPS. The "documents" check is the opposite case: that one really is about identity, so naming the step is exactly right.

Lining the two versions up against the two jobs:

Jobstep === 3STEPS + stepId
Identitythe number — but only if you already know the orderstepId, which reads as the screen's own name
Orderthe same number, doing double dutyposition in STEPS
Adding a step in the middlerenumber every comparison, everywhereinsert one line in STEPS
Typostep === 5 compiles and never matchesstepId === "documnets" is a type error

That last row is the one that matters most. The reordering pain is occasional; the compiler catching a misspelled branch is every single day.

Approval status: a finite set without order

Now the second comment. It's tempting to reach for the same ordered array — but check it against the same framework first, because the answer is different:

JobStepsApproval status
Identitywhich screenwhich state a request is in
Orderposition in the flownone — a request can go pending → approved, pending → rejected, or back to pending after a change request

Only one job to serve, so the ordered array is over-modelling. What this needs is a named set:

export const APPROVAL_STATUS = {
  PENDING: "pending",
  CHANGE_REQUEST: "change_request",
  APPROVED: "approved",
  REJECTED: "rejected",
} as const

export type ApprovalStatus =
  (typeof APPROVAL_STATUS)[keyof typeof APPROVAL_STATUS]
// "pending" | "change_request" | "approved" | "rejected"

Why not enum?

enum exists for exactly this, and it isn't wrong here. I still reach for the as const object, and the reason is fit rather than correctness — it matches the way modern TypeScript models a fixed set of string values.

The object is just JavaScript. APPROVAL_STATUS.PENDING is an ordinary property lookup that evaluates to "pending" — no new construct to learn.

One declaration gives you both a runtime value and a type. APPROVAL_STATUS.APPROVED is the value; ApprovalStatus is the type derived from it. And because that type is a union of string literals, the values stay ordinary strings:

function updateStatus(status: ApprovalStatus) {}

updateStatus(APPROVAL_STATUS.APPROVED) // ok
updateStatus("approved")               // ok
updateStatus("something")              // error

With an enum, only ApprovalStatus.APPROVED is accepted, while the matching string is rejected.

These values arrive from an API as strings. The as const object keeps them as strings and derives the union type from those same values. An enum creates a separate enum type of its own, and turning a general string into it takes validation or a cast.

Making the compiler find the missing cases

Naming the values fixes readability and typos. The bigger prize is turning "someone added a status and forgot four places" from a production bug into a build failure.

For branching logic, an exhaustive switch with a never guard:

function nextActionFor(status: ApprovalStatus) {
  switch (status) {
    case APPROVAL_STATUS.PENDING:
      return openApprovalDialog
    case APPROVAL_STATUS.CHANGE_REQUEST:
      return openEditForm
    case APPROVAL_STATUS.APPROVED:
      return downloadCertificate
    case APPROVAL_STATUS.REJECTED:
      return openAppealForm
    default: {
      const exhaustive: never = status
      throw new Error(`Unhandled status: ${exhaustive}`)
    }
  }
}

Add withdrawn to APPROVAL_STATUS and status is no longer never in the default branch, so the assignment fails to compile. For pure data lookups, Record gives the same guarantee with far less ceremony:

const STATUS_BADGE: Record<ApprovalStatus, { label: string; tone: BadgeTone }> = {
  pending: { label: "Pending review", tone: "warning" },
  change_request: { label: "Changes requested", tone: "neutral" },
  approved: { label: "Approved", tone: "success" },
  rejected: { label: "Rejected", tone: "danger" },
}

A missing key is a type error. Use Record when you're mapping a value to data, and save the switch for when you're actually branching.

Where the constants should live

Put constants next to what they describe — STEPS with the form, APPROVAL_STATUS with the approvals feature.

Resist a shared constants.ts: it always grows into a few hundred lines of unrelated values with no owner. A constant explains itself when it sits beside what uses it — a status list next to its schema; the same list in a dumping ground explains nothing.

❌ src/lib/constants.ts
   STEPS, APPROVAL_STATUS, THEME_COLORS, ... — one untraceable pile

✅ src/features/application/steps.ts
   src/features/approvals/status.ts

When not to do this

What to look for in review

This ends as a checklist for reviewers, not authors — because I answered both questions on my own PR without hesitation. I'd written the code that morning; the meaning was still loaded in my head, not on the page. When reading someone else's diff:

  • A literal inside a condition. === 3, === "approved", > 5 in an if is almost always a business rule in hiding — assume that by default, and make the author show you it isn't.
  • An index or ordinal driving behaviour. Anything that breaks when a list is reordered.
  • A switch over a finite type with no default. Ask what happens when a case is added.
  • And the one that catches everything else: if you have to ask the author what a value means, that exchange is the finding. Don't accept the answer in the thread — ask for it in the code.

The two comments on my PR weren't nitpicks. They were the review doing the one thing I couldn't do for myself.

References