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.
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 runsA 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:
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:
class Base { set thing(v: unknown) {} get thing(): unknown { return 1; } }
class Sub extends Base { thing = { a: 1 }; }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:
class Base { static set thing(v: unknown) {} static get thing(): unknown { return 1; } }
class Sub extends Base { static thing = { a: 1 }; }(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:
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.outboundreturns 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:
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:
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.