Problem
Memory.Create increments a counter on the module-level Metrics object:
// build/system/memory/create.mjs:25
export function Create(hidden, enumerable, options = {}) {
Metrics.create += 1
...
Metrics.create is a data property, so once Metrics is frozen that increment throws in strict mode. Assign, Clone, Discard and Update increment their own counters the same way, and every Type.* constructor routes through at least one of them — Type.String() via Create, Type.Optional() and Type.Unsafe() via Clone and Update. So this is not a case of metrics going stale; all schema construction fails:
TypeError: Cannot assign to read only property 'create' of object '#<Object>'
at Create (typebox/build/system/memory/create.mjs:25:11)
at String (typebox/build/type/types/string.mjs:14:11)
Because schemas are usually declared at module scope, this tends to surface as module initialisation throwing rather than as a failure at a call site.
Why anything would freeze it
A host that shares one module graph between isolated consumers has to freeze it, otherwise one consumer can observe or corrupt another's module state through the shared instance. Deep-freezing the shared exports is the standard way to enforce that.
We hit this in @temporalio/worker. It runs each workflow in its own V8 sandbox, each with a private copy of the module graph. That isolation regressed with webpack >= 5.108.0 — sandboxes ended up sharing module state, so one workflow could observe another's — and v1.20.1 restored it, along with the per-workflow copy of the graph. For us that came to hundreds of megabytes and OOM-killed the worker. The SDK's escape hatch shares nominated modules across all sandboxes and deep-freezes them, so a sandbox cannot mutate state another can see — on a best-effort basis, since a freeze cannot reach closure variables, Map/Set contents or private fields. Sharing anything that reaches TypeBox then throws on the first schema construction.
The same transitive-freeze discipline appears in SES / Hardened JavaScript, where harden() freezes an object graph so untrusted code cannot tamper with objects it shares with its host.
Reproduction
No framework needed. deepFreeze below is copied verbatim from @temporalio/worker — it is the code that broke for us, and it is what a deep freeze generally looks like.
import * as System from 'typebox/system'
import Type from 'typebox'
const { Memory } = System
function deepFreeze(object, visited = new WeakSet()) {
if (object == null || visited.has(object) || (typeof object !== 'object' && typeof object !== 'function')) return object
visited.add(object)
if (Object.isFrozen(object)) return object
if (typeof object === 'object') {
for (const name of [...Object.getOwnPropertyNames(object), ...Object.getOwnPropertySymbols(object)]) {
const value = object[name]
if (value && (typeof value === 'object' || typeof value === 'function')) {
try { deepFreeze(value, visited) } catch { /* not everything can be frozen */ }
}
}
}
return Object.freeze(object)
}
for (const exported of Object.values(System)) {
try { deepFreeze(exported) } catch { /* module namespace objects can't be frozen */ }
}
console.log('Metrics frozen?', Object.isFrozen(Memory.Metrics)) // true
Type.String() // TypeError: Cannot assign to read only property 'create'
On typebox@1.3.8 / Node 24 that prints Metrics frozen? true and then throws. Object.freeze(Memory.Metrics) on its own is the one-line version, though nothing would freeze only that object in practice.
Suggested fix
Hold the counters in module scope and expose them as accessors. Freezing an object does not disable its accessors, and the closure variables are not reachable by any freeze, so the increments keep working:
let create = 0
// ...
export const Metrics: TMetrics = {
get create() {
return create
},
set create(value: number) {
create = value
},
// ...
}
TMetrics is unchanged. Object.keys(Metrics) and { ...Metrics } produce identical output before and after, so the counters stay readable, enumerable and spreadable.
Worth noting for the shared-module case: nothing in TypeBox reads the counters — they are five += 1 writes and a declaration, and no behaviour branches on them. So a counter shared between sandboxes has no observable effect on schema construction, even though a deep freeze cannot reach it.
I have this on a branch with a test that freezes Metrics and asserts the counters still increment. deno task lint, deno task test (35,188 passing) and deno task build all pass, and running the reproduction above against a pack of that build prints Metrics frozen? true followed by no error — the fix survives the freeze rather than avoiding it. Happy to open a PR if you would like one; the readme asks for an issue first, hence this.
Alternative
A setting such as Settings.Set({ metrics: false }) would also avoid the write, and would sit naturally alongside useAcceleration. The drawback is that it has to be set before the first schema is constructed, which a library depending on TypeBox cannot guarantee and code inside a sandbox may have no opportunity to do at all. The accessor change needs no configuration, which is why I have suggested it first — but I am happy to implement whichever you prefer.
🤖 Drafted with Claude Code
Problem
Memory.Createincrements a counter on the module-levelMetricsobject:Metrics.createis a data property, so onceMetricsis frozen that increment throws in strict mode.Assign,Clone,DiscardandUpdateincrement their own counters the same way, and everyType.*constructor routes through at least one of them —Type.String()viaCreate,Type.Optional()andType.Unsafe()viaCloneandUpdate. So this is not a case of metrics going stale; all schema construction fails:Because schemas are usually declared at module scope, this tends to surface as module initialisation throwing rather than as a failure at a call site.
Why anything would freeze it
A host that shares one module graph between isolated consumers has to freeze it, otherwise one consumer can observe or corrupt another's module state through the shared instance. Deep-freezing the shared exports is the standard way to enforce that.
We hit this in
@temporalio/worker. It runs each workflow in its own V8 sandbox, each with a private copy of the module graph. That isolation regressed with webpack >= 5.108.0 — sandboxes ended up sharing module state, so one workflow could observe another's — and v1.20.1 restored it, along with the per-workflow copy of the graph. For us that came to hundreds of megabytes and OOM-killed the worker. The SDK's escape hatch shares nominated modules across all sandboxes and deep-freezes them, so a sandbox cannot mutate state another can see — on a best-effort basis, since a freeze cannot reach closure variables,Map/Setcontents or private fields. Sharing anything that reaches TypeBox then throws on the first schema construction.The same transitive-freeze discipline appears in SES / Hardened JavaScript, where
harden()freezes an object graph so untrusted code cannot tamper with objects it shares with its host.Reproduction
No framework needed.
deepFreezebelow is copied verbatim from@temporalio/worker— it is the code that broke for us, and it is what a deep freeze generally looks like.On
typebox@1.3.8/ Node 24 that printsMetrics frozen? trueand then throws.Object.freeze(Memory.Metrics)on its own is the one-line version, though nothing would freeze only that object in practice.Suggested fix
Hold the counters in module scope and expose them as accessors. Freezing an object does not disable its accessors, and the closure variables are not reachable by any freeze, so the increments keep working:
TMetricsis unchanged.Object.keys(Metrics)and{ ...Metrics }produce identical output before and after, so the counters stay readable, enumerable and spreadable.Worth noting for the shared-module case: nothing in TypeBox reads the counters — they are five
+= 1writes and a declaration, and no behaviour branches on them. So a counter shared between sandboxes has no observable effect on schema construction, even though a deep freeze cannot reach it.I have this on a branch with a test that freezes
Metricsand asserts the counters still increment.deno task lint,deno task test(35,188 passing) anddeno task buildall pass, and running the reproduction above against a pack of that build printsMetrics frozen? truefollowed by no error — the fix survives the freeze rather than avoiding it. Happy to open a PR if you would like one; the readme asks for an issue first, hence this.Alternative
A setting such as
Settings.Set({ metrics: false })would also avoid the write, and would sit naturally alongsideuseAcceleration. The drawback is that it has to be set before the first schema is constructed, which a library depending on TypeBox cannot guarantee and code inside a sandbox may have no opportunity to do at all. The accessor change needs no configuration, which is why I have suggested it first — but I am happy to implement whichever you prefer.🤖 Drafted with Claude Code