The Readonly Trap: Why TypeScript Strict Mode Hates Your Tests

May 03, 2026 • ArchyPress

Flowchart showing TypeScript TS2540 error diagnosis and fix pattern

You wrote 463 unit tests. Every single one passes locally. You push to CI and the pipeline goes red with 12 errors you've never seen before. Welcome to TypeScript strict mode's opinion about process.env.

The Scene of the Crime

Here's the setup: a Next.js 15 application with TypeScript 5 in strict mode. We'd just finished writing a comprehensive test suite — 463 unit tests covering SEO utilities, structured data generation, and a handful of other modules. Tests ran green locally using Node.js native node:test with tsx. Life was good.

Then we pushed. CI ran npm run typecheck (which calls tsc --noEmit) and immediately produced 12 errors of the form:

error TS2540: Cannot assign to 'NODE_ENV' because it is a read-only property.

Twelve identical errors, all in test files, all on lines that did something completely reasonable-looking: process.env.NODE_ENV = "production". This is the kind of test setup you've probably written a hundred times. So what gives?

Why TypeScript Says No

The answer lives in Node.js's type definitions. When you use @types/node, the process.env object is typed as NodeJS.ProcessEnv, which is defined as:

interface ProcessEnv { readonly NODE_ENV?: string; [key: string]: string | undefined; }

See that readonly modifier on NODE_ENV? In loose TypeScript (without strict mode or without strictPropertyInitialization), the compiler is lenient about this. But in strict mode, TypeScript enforces readonly properties rigorously. Assigning to a readonly property is a compile-time error. Period.

Flowchart showing how TypeScript strict mode makes process.env.NODE_ENV readonly and the type assertion solution path

The irony is that process.env.NODE_ENV is absolutely mutable at runtime. Node.js doesn't care — it's just an object property. TypeScript's type system is adding a constraint that reflects intent (you probably shouldn't reassign NODE_ENV in production code) but collides with reality (you definitely need to control it in tests).

The Fix: A Typed Alias

The solution is a single line at the top of your test file. Instead of fighting the type system, you create a typed alias that tells TypeScript "I know what I'm doing, please let me write to this object":

const env = process.env as Record<string, string | undefined>;

Now everywhere you previously wrote process.env.NODE_ENV = "production", you write env.NODE_ENV = "production" instead. Same runtime behavior. No type errors. The Record<string, string | undefined> type accurately reflects what process.env actually is — a bag of string keys with string-or-undefined values — without the readonly constraint.

Why Not Just Cast Each Assignment?

You could do (process.env as any).NODE_ENV = "production" on every line, but that's noisy, error-prone, and uses any which defeats the purpose of strict mode. A single alias at the top of the file is cleaner, still type-safe (you can't accidentally write a number to it), and trivially greppable if you ever need to audit test environment manipulation.

Bad: Inline any cast

(process.env as any).NODE_ENV = "test" — Loses all type safety, noisy on every line, easy to forget

Bad: @ts-ignore

// @ts-ignore above each line — Suppresses ALL errors on that line, masks real bugs

Good: Typed alias

const env = process.env as Record<string, string | undefined> — One line, type-safe, no readonly constraint

Also Good: Test helpers

A setEnv(key, value) utility function that encapsulates the cast — Best for large test suites with many env manipulations

The Pattern in Practice

Here's what a typical test file looks like before and after. The test logic doesn't change at all — you're just swapping which reference you use for writes:

Before (fails typecheck in strict mode):

import { describe, it, beforeEach } from 'node:test';

beforeEach(() => { process.env.NODE_ENV = 'test'; });

// TS2540: Cannot assign to 'NODE_ENV' because it is a read-only property.

After (passes typecheck, same runtime behavior):

import { describe, it, beforeEach } from 'node:test';

const env = process.env as Record<string, string | undefined>;

beforeEach(() => { env.NODE_ENV = 'test'; });

// All good. TypeScript is happy. Tests still pass. CI is green.

Why This Matters Beyond the Fix

This particular error has a few properties that make it especially frustrating:

  1. Tests pass locally — because your test runner (tsx, ts-node, vitest) transpiles TypeScript but doesn't do full type-checking. You only see the error when tsc runs in CI.

  2. It looks like a Node.js problem — the error message mentions process.env, which sounds like a runtime issue. But it's purely a type-level constraint.

  3. AI tools get confused by it — we actually had an AI co-pilot try to fix this by removing code blocks and introducing syntax errors (more on that in a companion post).

  4. It's project-specific — whether you hit this depends on your @types/node version, your tsconfig strictness, and whether you're assigning specifically to NODE_ENV vs other env vars.

The general lesson: when you enable strict mode in TypeScript (and you should), expect to encounter tension between type-level idealism and test-level pragmatism. The readonly modifier on NODE_ENV is philosophically correct — you really shouldn't mutate it in production code. But tests are a different universe where you need fine-grained control over the environment. The typed alias bridges that gap without compromising either goal.

Key Takeaways

Strict mode is worth it

The occasional friction with test setup is a tiny price for catching real bugs in production code. Don't relax strict mode — work with it.

Type assertions are tools, not crimes

A well-scoped 'as Record<string, string | undefined>' is not the same as 'as any'. Be precise about what you're asserting.

CI must run typecheck separately

Don't rely on your bundler or test runner to catch type errors. Always run tsc --noEmit as an explicit CI step.

Test your tests

If your test suite can pass all tests but fail typecheck, you have a gap in your CI pipeline. Close it.

Building with TypeScript strict mode?

We write about engineering patterns, CI/CD automation, and lessons learned from building production Next.js applications.

© 2026 Meet Archy
The Readonly Trap: TypeScript Strict Mode vs process.env | Archy Engineering | ArchyPress Platform