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 skips its own registration silently.
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 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:
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 from it.
The closest thing they get is TS4114, under noImplicitOverride — not part of --strict, but
set by @tsconfig/strictest, so plenty of projects have it on:
error TS4114: This member must have an 'override' modifier because it
overrides a member in the base class 'Base'.
That is not shadow detection. It is generic override hygiene, it names the wrong hazard, and the
fix it demands makes things worse: add override and the compiler goes quiet while the
registration is still being skipped. The instance case cannot be dismissed that way — TS2610
keeps firing with override present, because it is diagnosing the actual problem.
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, and falls through — and where it falls through to depends on how the
container is configured. On the package default (enableInternet is true) it calls
fetch(request), so the traffic you meant to intercept goes out unmediated. Follow the README's
own example, which pairs enableInternet = false with an allowedHosts list, and the allowlist
gate hands your configured hosts straight to the real origin for the same reason. Only with
internet disabled and no allowlist does it reach the default-deny path and answer
520 Origin is disallowed.
The failure has an unpleasant shape:
- It is silent. No throw, no warning, no type error.
- It usually fails open. The handler you wrote to redact, block or log never runs, and the request goes out anyway — which is the more dangerous half of this. Total egress denial is the loud version, and you only get it in the narrow case of internet disabled with no allowlist.
- 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 places it sets
a handler:
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");
// A data property is a shadowing field. An accessor is the class that declares
// the accessor in the first place, which is not a bug.
if (shadowed && !shadowed.get && !shadowed.set && !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. Delete the field and assign after the ` +
`class body instead: ${ctor.name}.outbound = handler;`
);
}
That converts an unmediated request in production into a one-line diagnosis at startup. Note
the wording of the remedy: the field has to be deleted, not merely followed by an assignment.
Appending MyContainer.outbound = handler below a static outbound = … field is a [[Set]]
that finds the own data property and overwrites it in place, never reaching the inherited
setter — the same trap one level down.
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.
Check that your interception is happening at all, not just that nothing broke. A handler
that silently never registered looks like traffic flowing normally, because it is — your
redaction, blocking or logging simply isn't in the path. If a uniform failure did appear instead,
that is the same bug wearing its loud face. Either way, 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 — apart from a generic override warning that its own fix silences — 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.