env.dev

Constants Best Practices: Naming, Immutability & Magic Numbers

Universal rules for good constants: SCREAMING_SNAKE_CASE with unit suffixes, real immutability per language, single source of truth, no magic numbers. Effective Java Item 22.

Last updated:

Good constants do three things: they name a value with intent, they are defined in exactly one place, and they cannot drift out of sync with code that depends on them. The conventions are nearly universal across languages — SCREAMING_SNAKE_CASE (PEP 8 in 1999, Java Code Conventions in 1999, JavaScript style guides since the early 2010s), unit suffixes in the name (TIMEOUT_MS not TIMEOUT), and "const by default, mutability by exception". This guide covers naming, real immutability, enum vs named constant, the single-source-of-truth rule, and the magic-number gotcha that Effective Java's Item 22 named and shamed.

What's the universal naming convention?

SCREAMING_SNAKE_CASE — uppercase letters with underscores between words — is the convention in C, Java, Python, JavaScript, Rust, Go (for unexported), and almost every other mainstream language. MAX_RETRY_COUNT is a constant. maxRetryCount is a regular variable that happens not to change. The visual distinction matters because it carries information at a glance.

Three exceptions worth knowing:

  • Go uses PascalCase for exported constants and camelCase for unexported. Capitalization controls visibility — there is no separate private keyword.
  • JavaScript / TypeScript increasingly uses camelCase for module-scoped configuration values (especially inside objects), reserving SCREAMING_SNAKE_CASE for true module- level singletons. Both forms coexist; pick one and stay consistent.
  • Always include the unit: TIMEOUT_MS, not TIMEOUT. CACHE_TTL_SECONDS, not CACHE_TTL. This single rule prevents an entire category of bugs where someone passes seconds to a function expecting milliseconds.

Why does "const" rarely mean "immutable"?

In most mainstream languages, const / final blocks reassignment of the binding, not mutation of the value. A const object in JavaScript can have its properties changed. A final List in Java can still accept .add(). Rust is the exception: bindings are immutable by default, and const is genuinely a compile-time literal.

For real immutability, layer additional guarantees:

typescript
// JavaScript / TypeScript — best practice: as const + readonly tuples
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
// type: readonly ['GET', 'POST', 'PUT', 'DELETE'] — push() rejected by TS

const STATUS_CODES = {
  ok: 200,
  notFound: 404,
  serverError: 500,
} as const;
// All values are literal types, all properties are readonly
java
// Java 9+ — List.of, Map.of, Set.of return truly unmodifiable collections
public static final List<String> SUPPORTED =
    List.of("png", "jpg", "webp", "gif");

// Pre-Java 9
public static final List<String> LEGACY = Collections.unmodifiableList(
    Arrays.asList("a", "b", "c")
);
python
# Python — prefer immutable built-ins for constant collections
ALLOWED_EXTENSIONS = ("py", "pyi", "pyx")     # tuple, not list
RESERVED_WORDS     = frozenset({"if", "else", "while", "for"})

# Read-only dict view via MappingProxyType
from types import MappingProxyType
CONFIG = MappingProxyType({"debug": False, "log_level": "info"})

When should you use an enum instead of a named constant?

Enums and named constants both bind a name to a value, but they encode different intent. Use an enum when the values form a closed, finite, mutually-exclusive set with semantics — HTTP methods, days of the week, finite-state-machine states. Use named constants when values are independent, the set is open, or the values have external meaning (HTTP status codes, exit codes).

The killer feature of enums in modern languages is exhaustive matching. Java 14's switch expressions, Rust's match, Python 3.10's match/case, TypeScript's never trick — all three force you to handle every variant or fail the build. Adding a new variant safely propagates as a compile error, which is exactly what you want.

typescript
// TypeScript: as const + union — modern alternative to enum
const DIRECTION = {
  Up: 'UP',
  Down: 'DOWN',
  Left: 'LEFT',
  Right: 'RIGHT',
} as const;

type Direction = typeof DIRECTION[keyof typeof DIRECTION];

function move(d: Direction): string {
  switch (d) {
    case 'UP':    return 'north';
    case 'DOWN':  return 'south';
    case 'LEFT':  return 'west';
    case 'RIGHT': return 'east';
    default: {
      // If you add a variant to DIRECTION and forget here,
      // _exhaustive becomes type "string" instead of "never" and the build fails.
      const _exhaustive: never = d;
      return _exhaustive;
    }
  }
}

What does "single source of truth" actually require?

Every constant in your codebase should be defined in exactly one place. Repetition is a maintenance bomb: if the same magic number lives in five files and one usage needs to change, you must find and edit every copy — and missing one creates a silent inconsistency that surfaces in production.

The discipline extends to derived values. If MAX_ITEMS_PER_PAGE is 50 and you need MAX_TOTAL_ITEMS to be ten pages worth, define it as MAX_ITEMS_PER_PAGE * 10, not the literal 500. Otherwise the relationship is invisible and the two values will drift.

typescript
// config/limits.ts — one place, derived where possible
export const MAX_ITEMS_PER_PAGE   = 50;
export const MAX_TOTAL_ITEMS      = MAX_ITEMS_PER_PAGE * 10;        // 500
export const REQUEST_TIMEOUT_MS   = 30_000;
export const RETRY_BACKOFF_BASE_MS = 1_000;
export const MAX_RETRY_ATTEMPTS    = 3;
export const TOTAL_TIMEOUT_BUDGET_MS =
  REQUEST_TIMEOUT_MS * (MAX_RETRY_ATTEMPTS + 1);  // 120_000

How do you avoid magic numbers?

A magic number is a literal value that appears in code without a name to explain it. Effective Java Item 22 gave the antipattern a label; ESLint's no-magic-numbers rule and Pylint's equivalents flag it automatically. Replace each unexplained literal with a named constant whose name describes why the value was chosen, not just what it is.

typescript
// Bad — what does 3 mean? Why 30_000?
if (retries > 3) throw new Error('too many');
const timeout = 30_000;

// Good — name explains intent
const MAX_RETRY_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 30_000;

if (retries > MAX_RETRY_ATTEMPTS) throw new Error('too many');
const timeout = REQUEST_TIMEOUT_MS;

The universally-tolerated literals are 0, 1, -1, "", and true/false. Array index 0, loop increment 1, sentinel value -1 — these are idioms. Everything else should be named.

How do you document a constant well?

A documented constant carries: its purpose, its unit (if applicable), its valid range, and the reason for its specific value. The "why" matters more than the "what" — a future maintainer who knows why the limit is 5 can decide whether 7 would be safe; a maintainer who only knows that the limit is 5 has to guess.

typescript
/**
 * Maximum concurrent WebSocket connections per user.
 *
 * Set to 5 based on load testing in May 2025 — higher values
 * caused memory pressure on the connection broker at our current
 * scale (~50k MAU). Revisit if we move to a sharded broker.
 */
export const MAX_WS_CONNECTIONS_PER_USER = 5;

/**
 * Earth's gravitational acceleration in m/s², per ISO 80000-3.
 * Used in the projectile-motion physics module.
 */
export const GRAVITY_STANDARD = 9.806_65;

Practical checklist

  • Name every literal that isn't 0, 1, or -1. Lint rules can enforce this if you want a hard line.
  • Always include the unit in the name: ms, seconds, bytes, KiB.
  • One file per concern, not one file for everything: config/limits.ts, config/urls.ts, config/feature-flags.ts.
  • Group related values in an object / namespace / enum so IDE autocomplete surfaces them together.
  • Document the why, especially for thresholds chosen from measurement or external standards (RFCs, regulations, vendor limits).
  • Pick a language-specific immutability strategy: as const in TS, List.of / records in Java, tuple / frozenset in Python, const in Rust / Go. See the per-language guides: JS/TS, Python, Go, Rust, Java.

For practical compiler tricks — tree shaking, constant folding, branded types, test fixtures — see constants tips & tricks.

Was this helpful?

Read next

Constants in JavaScript & TypeScript: const & as const

JS const blocks reassignment, not mutation. Real immutability comes from Object.freeze (ES5) or — better — TypeScript's as const assertion (TS 3.4, 2019) with zero runtime cost.

Continue →

Frequently Asked Questions

What is the universal naming convention for constants?

SCREAMING_SNAKE_CASE in C, Java, Python, JavaScript, Rust. Go uses PascalCase for exported and camelCase for unexported (capitalization controls visibility). Always include the unit in the name: TIMEOUT_MS, MAX_SIZE_BYTES, CACHE_TTL_SECONDS — this single rule prevents an entire category of unit-mismatch bugs.

Why does const rarely mean immutable?

In most languages const blocks reassignment of the binding, not mutation of the value. A const object in JS can have properties changed; a final List in Java can still accept .add(). For real immutability layer additional guarantees: as const in TS, List.of in Java, tuple/frozenset in Python. Rust is the exception — bindings are immutable by default.

When should I use an enum instead of named constants?

Enum for closed, finite sets with semantics — HTTP methods, finite-state-machine states. Named constants for independent values, open sets, or values with external meaning (HTTP status codes, exit codes). The killer feature of modern enums is exhaustive matching: switch expressions force you to handle every variant.

What counts as a magic number?

Any literal value in code without a name to explain its purpose. Effective Java Item 22 named the antipattern; ESLint no-magic-numbers and Pylint flag it. Universal exceptions: 0, 1, -1, "", true/false. Everything else gets a named constant whose name describes WHY the value was chosen.

Stay up to date

Get notified about new guides, tools, and cheatsheets.