Proposal: replace the Date Proxy in testing/time.ts with a plain function. Follow-up to #7309, which fixed the subclass bug inside the Proxy and deliberately stopped there.
Why
The Deno Style Guide says: "Meta-programming is discouraged. Including the use of Proxy. Be explicit, even when it means more code." The Proxy exists to deliver three deviations from the real Date: now() reads the fake clock, zero-arg new Date() reads the fake clock, and Date() without new returns the fake time as a string. Three deviations do not need a Proxy to be explicit about.
The style guide is the smaller reason. The bigger one is a footgun the Proxy causes. It has no set or defineProperty trap, so writes fall through to the real constructor, while the get trap answers now before looking at the target. Result:
using time = new FakeTime(1000);
using nowSpy = spy(Date, "now"); // patches the REAL Date.now
Date.now(); // still returns 1000 from the fake
nowSpy.calls.length; // 0
The spy silently misses. Same for stub(Date, "parse", ...) inside a fake block: the stub lands on _internals.Date and is bypassed until restore(). A plain function with an own now property makes those writes land on the fake, where the test expects them.
Sketch
function FakeDate(this: unknown, ...args: unknown[]) {
if (new.target === undefined) {
return new _internals.Date(fakeTimeNow()).toString();
}
if (args.length === 0) args = [fakeTimeNow()];
return Reflect.construct(_internals.Date, args, new.target);
}
// Statics resolve live through the real constructor. Writes stay on the fake.
Object.setPrototypeOf(FakeDate, _internals.Date);
Object.defineProperties(FakeDate, {
prototype: { value: _internals.Date.prototype, writable: false },
now: { value: fakeTimeNow, writable: true, configurable: true },
length: { value: 7, configurable: true },
name: { value: "Date", configurable: true },
});
Then globalThis.Date = FakeDate in overrideGlobals(). About 15 lines net.
Each piece earns its place. new.target === undefined is the call-without-new case. Reflect.construct with new.target keeps subclass prototypes, same as #7309. setPrototypeOf to the real Date means parse and UTC resolve up the chain on every read, so a stub installed on the real constructor at any time is visible through the fake, exactly as the Proxy behaves today. prototype must be the real Date.prototype or instanceof Date breaks for real instances while faked. length and name are own on every function and would otherwise report 0 and "FakeDate".
What changes
Probed on Deno 2.9.6 against the current Proxy:
| Observation |
Proxy today |
Plain function |
Date.now(), new Date(), Date(), instanceof, argument passthrough |
fake |
same |
Date.parse stubbed on the real constructor, before or during a fake block |
stub visible |
same |
spy(Date, "now") while faked |
patches real, 0 calls recorded |
patches fake, calls recorded |
Date.parse = f while faked |
patches the real Date |
patches the fake only |
Object.hasOwn(Date, "parse") |
true |
false |
Object.getPrototypeOf(Date) |
Function.prototype |
real Date |
Date.toString() on the constructor |
[native code] |
function source |
Every read matches. The write rows are the intended change. The last three rows are the cost.
Tradeoffs, honestly
The reflection differences are real. hasOwn and getPrototypeOf on the constructor change, and Date.toString() shows source instead of [native code]. I found nothing in this repo that depends on any of them, and code that introspects the Date constructor object rather than using it is rare. But the Proxy has zero such regressions and this has three.
The write isolation is a behavior change for anyone who assigns to Date statics inside a FakeTime block. Today that write leaks to the real constructor and is ignored. After this it lands on the fake and works. I would call that a fix, not a break, but it belongs in the changelog either way.
I considered and rejected copying the real constructor's own descriptors onto the function instead of prototype delegation. It fixes hasOwn and getPrototypeOf parity, but turns statics into a snapshot: a Date.parse stub installed after import and before new FakeTime() goes invisible. Live reads matter more than two reflective properties.
Also rejected class FakeDate extends _internals.Date. Classes throw when called without new, and user subclasses built under fake time would chain through FakeDate.prototype, leaking the fake type into objects that outlive restore().
Tests
Seven cases pin the table: subclass keeps its prototype, zero-arg construction tracks tick(), new Date(undefined) stays invalid, a captured constructor works after restore(), static reads are live, static writes are isolated, and length/name/prototype identity. Four of those already landed in #7309.
Happy to open the PR if there is appetite. If the reflection rows are a dealbreaker, the Proxy as fixed in #7309 is fine and this can close.
Proposal: replace the
DateProxy intesting/time.tswith a plain function. Follow-up to #7309, which fixed the subclass bug inside the Proxy and deliberately stopped there.Why
The Deno Style Guide says: "Meta-programming is discouraged. Including the use of Proxy. Be explicit, even when it means more code." The Proxy exists to deliver three deviations from the real
Date:now()reads the fake clock, zero-argnew Date()reads the fake clock, andDate()withoutnewreturns the fake time as a string. Three deviations do not need a Proxy to be explicit about.The style guide is the smaller reason. The bigger one is a footgun the Proxy causes. It has no
setordefinePropertytrap, so writes fall through to the real constructor, while thegettrap answersnowbefore looking at the target. Result:The spy silently misses. Same for
stub(Date, "parse", ...)inside a fake block: the stub lands on_internals.Dateand is bypassed untilrestore(). A plain function with an ownnowproperty makes those writes land on the fake, where the test expects them.Sketch
Then
globalThis.Date = FakeDateinoverrideGlobals(). About 15 lines net.Each piece earns its place.
new.target === undefinedis the call-without-newcase.Reflect.constructwithnew.targetkeeps subclass prototypes, same as #7309.setPrototypeOfto the realDatemeansparseandUTCresolve up the chain on every read, so a stub installed on the real constructor at any time is visible through the fake, exactly as the Proxy behaves today.prototypemust be the realDate.prototypeorinstanceof Datebreaks for real instances while faked.lengthandnameare own on every function and would otherwise report 0 and "FakeDate".What changes
Probed on Deno 2.9.6 against the current Proxy:
Date.now(),new Date(),Date(),instanceof, argument passthroughDate.parsestubbed on the real constructor, before or during a fake blockspy(Date, "now")while fakedDate.parse = fwhile fakedDateObject.hasOwn(Date, "parse")Object.getPrototypeOf(Date)Function.prototypeDateDate.toString()on the constructor[native code]Every read matches. The write rows are the intended change. The last three rows are the cost.
Tradeoffs, honestly
The reflection differences are real.
hasOwnandgetPrototypeOfon the constructor change, andDate.toString()shows source instead of[native code]. I found nothing in this repo that depends on any of them, and code that introspects theDateconstructor object rather than using it is rare. But the Proxy has zero such regressions and this has three.The write isolation is a behavior change for anyone who assigns to
Datestatics inside aFakeTimeblock. Today that write leaks to the real constructor and is ignored. After this it lands on the fake and works. I would call that a fix, not a break, but it belongs in the changelog either way.I considered and rejected copying the real constructor's own descriptors onto the function instead of prototype delegation. It fixes
hasOwnandgetPrototypeOfparity, but turns statics into a snapshot: aDate.parsestub installed after import and beforenew FakeTime()goes invisible. Live reads matter more than two reflective properties.Also rejected
class FakeDate extends _internals.Date. Classes throw when called withoutnew, and user subclasses built under fake time would chain throughFakeDate.prototype, leaking the fake type into objects that outliverestore().Tests
Seven cases pin the table: subclass keeps its prototype, zero-arg construction tracks
tick(),new Date(undefined)stays invalid, a captured constructor works afterrestore(), static reads are live, static writes are isolated, andlength/name/prototypeidentity. Four of those already landed in #7309.Happy to open the PR if there is appetite. If the reflection rows are a dealbreaker, the Proxy as fixed in #7309 is fine and this can close.