The Static Accessor TypeScript Won't Protect

TypeScript has an error for overriding an accessor with a field. It does not fire for static members, and a library built on that pattern fails silently and closed.

Here are two classes. One of them silently does nothing.

ts
class Base {
  static set config(v: unknown) { register(this.name, v); }
  static get config(): unknown { return lookup(this.name); }
}

class A extends Base {
  static config = { retries: 3 };   // ← the setter never runs
}

class B extends Base {}
B.config = { retries: 3 };          // ← the setter runs

A compiles without a single diagnostic. A.config even reads back { retries: 3 }, so it looks like it worked. But register was never called, because that read is hitting an own property that now sits in front of the inherited getter.

If register is what wires up your library, A is broken and nothing told you.

Why the field wins

A class field is not an assignment. Since ES2022 — and in TypeScript whenever useDefineForClassFields is on, which is the default for target: ES2022 and above — a field declaration is installed with [[DefineOwnProperty]], not [[Set]].

The distinction is the whole bug:

  • [[Set]] walks the prototype chain. It finds the inherited setter and calls it.
  • [[DefineOwnProperty]] does not walk anything. It stamps a new own data property directly onto the object, shadowing whatever the chain had.

So static config = … doesn't call Base's setter. It replaces it, on that subclass.

You can watch the emit flip:

ts
class B { static set outbound(h: unknown) { console.log("SETTER RAN"); } }
class C extends B { static outbound = () => {}; }
target emitted semantics result
ES2021 C.outbound = () => {}; after the class [[Set]] prints SETTER RAN
ES2022+ static outbound = () => {}; inside the class [[DefineOwnProperty]] silence

Same source. Same library. One config line between working and not — which means this can arrive during a routine target bump, in a commit that touches no application code.

TypeScript already has an error for this

Here is the part that surprised me. TypeScript does diagnose this pattern:

ts
class Base { set thing(v: unknown) {} get thing(): unknown { return 1; } }
class Sub extends Base { thing = { a: 1 }; }
text
error TS2610: 'thing' is defined as an accessor in class 'Base',
but is overridden here in 'Sub' as an instance property.

That is exactly the right error, and it names exactly the right hazard.

Now make both members static:

ts
class Base { static set thing(v: unknown) {} static get thing(): unknown { return 1; } }
class Sub extends Base { static thing = { a: 1 }; }
text
(no output)

No error. No warning. Not under --strict. I checked this on TypeScript 6.0.2.

The compiler knows this shape is dangerous enough to have a dedicated diagnostic for it, and that diagnostic covers instance members only. Static members get nothing.

What this looks like in a real library

@cloudflare/containers lets you intercept a container's outbound network traffic by putting a handler on the container class. Three of its configuration points — outbound, outboundByHost and outboundHandlers — are static accessors, and their setters are the only writers of the handler registry the runtime proxy reads from.

The README documents all three as class fields:

ts
static outbound = (req: Request) => {
  return new Response(`Hi ${req.url}, I can't handle you`);
};

Follow that on a modern tsconfig and no handler is ever registered. The proxy looks up the registry, finds nothing, falls through to the default-deny path, and refuses every outbound request from the container with 520 Origin is disallowed.

The failure has an unpleasant shape:

  • It is silent. No throw, no warning, no type error.
  • It fails closed. The symptom is total egress denial, which reads like a networking or permissions problem — so you go and audit your allowlists and your firewall rules first.
  • It looks correct. MyContainer.outbound returns your function, because you defined it as an own property. Every reflexive check you'd run says the handler is there.
  • It is far from its cause. The registration happens at module-eval time; the failure shows up on a request, in a different component, later.

The tell is the mismatch between the two sides. MyContainer.outbound reads back your handler, but the registry the proxy consults is empty — because the only thing that writes that registry is the setter you shadowed.

I filed this as cloudflare/containers#247 with a runnable reproduction, and opened #248 to fix the docs.

The detail I found most interesting: the same repository already documents the correct form. docs/egress.md — linked from the README — uses the assignment form in all six of its examples:

ts
MyContainer.outboundByHost = { /* … */ };

Two documents in one repo, disagreeing, and one of them costs you all your egress. Nothing caught the drift, because nothing could: no test, no type, no lint rule sees the difference between prose that works and prose that doesn't.

If you maintain a library

Don't put a static accessor in your public API if you can avoid it. A static method (MyContainer.setOutbound(handler)) cannot be shadowed by a field, and the failure mode simply doesn't exist. This is the fix that removes the class of bug rather than documenting around it.

If you keep the accessor, detect the shadow. You know what a correct registration looks like. At the point where you'd read the registry, check whether the constructor carries an own property that a registration would have consumed:

ts
const shadowed = Object.getOwnPropertyDescriptor(ctor, "outbound");
if (shadowed && !registry.has(ctor.name)) {
  throw new Error(
    `${ctor.name}.outbound was declared as a static class field, which shadows the ` +
    `inherited setter and skips registration. Assign it after the class declaration: ` +
    `${ctor.name}.outbound = handler;`
  );
}

That converts a fail-closed 520 in production into a one-line diagnosis at startup.

Grep your own docs. If your API is an accessor, every static yourThing = in your README is a broken example. It costs one CI grep to keep them honest, and — as above — your docs can disagree with each other for a long time without anyone noticing.

If you consume one

Assume every static x = … in a README is untested prose. Documentation examples are rarely type-checked and almost never executed. That one is a class field is invisible to the person who wrote it, because it worked when they wrote it.

Check the type declarations, not the docs. static get x() / static set x() in the .d.ts means assign after the class body. It takes ten seconds and it is authoritative in a way the README is not.

Be suspicious of a total, uniform failure right after a config change. "Nothing works at all" is rarely a subtle bug in your logic and often a registration that silently didn't happen. If a target bump landed near the incident, look there before you audit your network rules.

The general shape

Class fields and accessors are both ways of writing x = value in a class body, and they do fundamentally different things. Where a base class exposes a static accessor with side effects in the setter, a subclass field silently replaces it — the compiler will warn you on instance members and stay quiet on static ones.

Any library whose configuration is "set this static property and we'll register it" is exposed. It's worth checking whether yours is.

About this story

This article was written by Gen-AI using GPT 5.5 or Opus 4.7. Verify technical guidance before using it in production systems.

Advertisement ad.endcap · 336×280

Related reading

performance PNPM popularity is growing rapidly, is it time to ditch npm & yarn?