Skip to content
Santi020k

Search

Match titles, tags, and descriptions. Arrow keys to move, Enter to open, Esc to close.

Open from the page (not while typing in a field): / · K or CtrlK

    Permalink to this article
    Blog Article
    typescript

    Avoid Magic Strings in TypeScript and JavaScript

    Replace ad-hoc string literals with clearer domain language, stronger typing, and safer refactors.

    4 min read

    619

    Magic strings are one of those problems that look harmless until the codebase gets busy. A single inline string is rarely the issue. The trouble starts when the same value becomes part of control flow, UI states, analytics keys, API mappings, or error handling in several places at once.

    At that point, every string literal becomes a hidden contract.

    What counts as a magic string

    I am not talking about every hard-coded sentence in a UI. I am talking about strings that carry meaning the system depends on, but that meaning is not modeled anywhere.

    Examples:

    • Status names used in conditions
    • Event names sent to analytics
    • Role names used for permissions
    • Error identifiers mapped to messages
    • Cache keys and route segments repeated by hand

    The problem is not that they are strings. The problem is that they are important strings with no shared source of truth.

    Why they create drag

    Magic strings create several kinds of risk at once:

    • They are easy to mistype.
    • They are difficult to search confidently when naming is inconsistent.
    • They encourage copy-paste logic instead of domain modeling.
    • They reduce TypeScript’s ability to help us.
    • They make refactors more expensive than they need to be.

    Most importantly, they hide intent. if (status === 'approved') is readable enough in isolation. But if 'approved', 'pending_review', and 'rejected' appear across a dozen files with slightly different handling, the system has already started leaking its vocabulary.

    Better options in TypeScript

    The right alternative depends on how the value is used.

    Named constant objects

    For many cases, a frozen object is enough:

    export const ORDER_STATUS = {
      approved: 'approved',
      pendingReview: 'pending_review',
      rejected: 'rejected'
    } as const
     
    export type OrderStatus = typeof ORDER_STATUS[keyof typeof ORDER_STATUS]

    This gives us autocompletion, centralized naming, and a type we can reuse.

    Literal unions

    If the set is small and we do not need runtime access, unions can be even cleaner:

    export type Environment = 'development' | 'staging' | 'production'

    Maps for user-facing copy

    When we need to translate machine values into readable messages, I prefer explicit maps:

    const ERROR_MESSAGES: Record<ApiErrorCode, string> = {
      RATE_LIMITED: 'Please wait a moment and try again.',
      UNAUTHORIZED: 'You need to sign in again.',
      UNKNOWN: 'Something went wrong. Please try again.'
    }

    That structure makes intent visible and keeps the user-facing copy close to the domain concept it depends on.

    When inline strings are fine

    Not every string needs abstraction.

    Inline strings are fine when they are:

    • One-off presentation copy
    • Local to a test and helpful for readability
    • Truly not part of a shared contract

    The rule I use is simple: if changing the string in one place should probably change it somewhere else too, it likely deserves a named representation.

    A refactor rule that scales

    When I find a magic string that affects behavior, I usually apply this sequence:

    1. Name the concept
    2. Centralize the allowed values
    3. Give the values a type
    4. Replace conditional branches to use the named source
    5. Remove string comparisons that are now redundant

    That process improves the code without turning a small cleanup into a large migration.

    Clarity beats cleverness

    The goal is not abstraction for its own sake. The goal is to make the system speak in its own domain language.

    That is the real benefit. Constants, unions, and maps are just tools. The deeper win is that reviews get easier, intent becomes clearer, and TypeScript can help us guard the contracts the system already depends on.

    If a string controls behavior, treat it like part of the design, not like incidental text.

    Share this piece
    Keep reading

    More writing in the same thread.

    A few more posts that overlap in topic, tooling, or the engineering tradeoffs behind this article.

    Boosting Code Quality and Efficiency with My ESLint Configuration Library
    Writing Case Study 5 min read

    Boosting Code Quality and Efficiency with My ESLint Configuration Library

    Reusable ESLint library for React, Next.js, and TypeScript projects. Enforces code quality with flat config support and simplifies the move from ESLint 8 to 9.

    In series: ESLint in Practice · Part 2

    Get new posts in your inbox

    Low volume — engineering notes, architecture writeups, and occasional career updates. Prefer a feed? Subscribe via RSS .

    We never share your email. Privacy policy .