env.dev

Constants Tips & Tricks

Practical techniques for getting the most out of constants — from compiler optimizations to type safety patterns and testing strategies.

Compile-Time vs Runtime Constants

Not all constants are created equal. Some are known at compile time and can be embedded directly into the binary or bundle, while others are determined when the application starts. Understanding this distinction is crucial for both performance and correctness.

Compile-time constants have values that the compiler can determine during compilation. In Rust, a const declaration must be a compile-time expression — the compiler evaluates it and inlines the result wherever the constant is used. In Go, const is limited to expressions that the compiler can evaluate at build time: numbers, strings, booleans, and simple arithmetic on other constants. In C and C++, constexpr guarantees compile-time evaluation, allowing complex expressions including function calls to be resolved before the program runs.

text
// Rust: const is always compile-time
const MAX_BUFFER_SIZE: usize = 1024 * 1024; // 1 MiB
const HEADER_BYTES: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes

// Go: const is limited to untyped or basic-typed values
const MaxRetries = 3
const Pi = 3.14159265358979323846
const Greeting = "hello"

// C++: constexpr enables compile-time function evaluation
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int fact10 = factorial(10); // computed at compile time

Runtime constants are values that do not change after initialization but cannot be known until the program starts. A database connection pool size read from an environment variable, a configuration file path resolved at startup, or a timestamp captured when the server boots — these are all runtime constants. In JavaScript and TypeScript, every const is a runtime constant because the language has no compile-time evaluation phase (aside from bundler transformations).

Prefer compile-time constants whenever possible. They are faster (no initialization cost), safer (the compiler verifies them), and enable more aggressive optimizations.

Tree Shaking and Dead Code Elimination

Tree shaking is the process by which a JavaScript bundler (Webpack, Rollup, esbuild, Vite) removes unused exports from your final bundle. Constants play a special role in this process because they are often used conditionally, and the bundler can eliminate entire code paths when it determines a constant makes a branch unreachable.

For tree shaking to work effectively with constants, they must be defined as ES module exports. CommonJS modules (module.exports) cannot be reliably tree-shaken because their exports are dynamic. Use export const for constants that consumers may or may not import:

typescript
// constants.ts — each export can be independently tree-shaken
export const FEATURE_A_ENABLED = true;
export const FEATURE_B_ENABLED = false;
export const MAX_FILE_SIZE = 10 * 1024 * 1024;

// consumer.ts — if FEATURE_B_ENABLED is never imported, it is removed
import { FEATURE_A_ENABLED, MAX_FILE_SIZE } from './constants.ts';

Bundlers configured for production mode will also perform dead code elimination on conditional branches. If a constant is false and used in an if statement, the entire block is removed. This is why build-time feature flags using constants are so effective — disabled features add zero bytes to the production bundle.

Constant Folding Optimizations

Constant folding is a compiler optimization where expressions involving only constants are evaluated at compile time rather than at runtime. Most modern compilers and JavaScript engines perform constant folding automatically.

javascript
// Before constant folding
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;
const SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY;
// After folding: SECONDS_PER_DAY = 86400

// The compiler replaces the multiplication with the precomputed result.
// In JavaScript, V8 does this at parse time for literal expressions.

You can help the compiler fold more aggressively by expressing derived constants in terms of other constants using only arithmetic and comparison operators. Avoid function calls in constant definitions unless the language guarantees compile-time evaluation (like Rust's const fn or C++ constexpr). In JavaScript, Math.pow(2, 10) is not folded at compile time — use 2 ** 10 or the literal 1024 instead.

Constant folding also applies to string concatenation. The compiler or bundler can merge 'https://' + 'api.example.com' + '/v1' into a single string at build time, eliminating the runtime concatenation. This is why defining URL constants as template literals or concatenated strings has no performance penalty in optimized builds.

Type Safety with Constants

Constants are an excellent opportunity to leverage your type system for stronger guarantees. Instead of typing a constant as string or number, narrow it to a literal type or a branded type that prevents accidental mixing with other values of the same primitive type.

In TypeScript, the as const assertion is the most powerful tool for constant type safety. It narrows literals to their exact values, makes arrays readonly tuples, and makes object properties readonly with literal types:

typescript
// Without as const: type is string[]
const ROLES = ['admin', 'editor', 'viewer'];

// With as const: type is readonly ['admin', 'editor', 'viewer']
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'

// Now TypeScript catches invalid roles at compile time
function hasRole(role: Role) { /* ... */ }
hasRole('admin');   // OK
hasRole('superuser'); // Type error!

Branded types take type safety a step further by preventing values that have the same primitive type from being used interchangeably. A user ID and a product ID might both be strings, but they represent fundamentally different things and should not be mixed:

typescript
type UserId = string & { readonly __brand: 'UserId' };
type ProductId = string & { readonly __brand: 'ProductId' };

function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }

const userId = 'user_123' as UserId;
const productId = 'prod_456' as ProductId;

getUser(userId);      // OK
getUser(productId);   // Type error — cannot mix branded types

In Rust, newtypes serve the same purpose — wrapping a primitive in a single-field struct prevents accidental mixing while adding no runtime overhead. In Java, enums provide type-safe constants with exhaustiveness checking in switch expressions.

Constants in Configuration

Feature flags, rate limits, pagination sizes, timeout durations, and retry thresholds are all constants in practice — values that change rarely and should be defined in a single place. Defining these as named constants (rather than scattering literal values throughout your codebase) has several benefits: clarity of intent, ease of tuning, and the ability to override them per environment.

typescript
// config/limits.ts
export const MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB
export const MAX_ITEMS_PER_PAGE = 100;
export const DEFAULT_PAGE_SIZE = 25;
export const REQUEST_TIMEOUT_MS = 30_000;
export const MAX_RETRY_ATTEMPTS = 3;
export const RETRY_BACKOFF_BASE_MS = 1_000;

For feature flags specifically, constants work well for flags that are known at build time. A flag like ENABLE_NEW_CHECKOUT that is set to true or false before deployment can be tree-shaken, meaning the old code path is entirely removed from the production bundle. For flags that need to be toggled at runtime without redeploying, use a feature flag service instead — but still define the flag names as constants to avoid typos and enable type checking.

Numeric thresholds deserve special attention. Always include the unit in the constant name: TIMEOUT_MS not TIMEOUT, MAX_SIZE_BYTES not MAX_SIZE, CACHE_TTL_SECONDS not CACHE_TTL. This prevents an entire category of bugs where a developer passes milliseconds to a function expecting seconds or vice versa.

Constants in Testing

Tests benefit enormously from well-defined constants. Test fixtures — the known input data and expected output values that your tests rely on — should be defined as constants rather than inline literals. This makes tests readable, maintainable, and resistant to copy-paste errors.

typescript
// test/fixtures.ts
export const TEST_USER = {
  id: 'user_test_001',
  email: 'test@example.com',
  name: 'Test User',
  role: 'editor',
} as const;

export const EXPECTED_PAGINATION = {
  defaultPageSize: 25,
  maxPageSize: 100,
  firstPage: 1,
} as const;

// In your test file
import { TEST_USER, EXPECTED_PAGINATION } from './fixtures.ts';

test('returns paginated results with default page size', () => {
  const result = paginate(items);
  expect(result.pageSize).toBe(EXPECTED_PAGINATION.defaultPageSize);
});

Constants also help when testing boundary conditions. If your application has a MAX_ITEMS_PER_PAGE constant, your tests should import and reference that same constant rather than hardcoding the number. This way, when the limit changes, your tests automatically test the correct boundary without any updates.

For snapshot testing and golden file testing, define expected values as constants in a dedicated fixtures file. This creates a clear separation between "what the test expects" and "how the test verifies it," making test failures easier to diagnose. When a test fails, you can look at the fixture constant to understand what was expected, then compare it to the actual output.

Finally, use constants for error messages, status codes, and other values that tests need to match exactly. Rather than asserting expect(err.code).toBe(404), import HTTP_NOT_FOUND from your constants module. This protects your tests from breaking if you refactor error codes into an enum later, and it makes the assertion's intent unmistakable.