Skip to content

@studnicky/clock

Wall-clock and monotonic time primitives with injectable providers for deterministic testing.

Install

bash
pnpm add @studnicky/clock

Usage

Build a Clock instance with a provider, then call now() for epoch-ms and hrtime() for nanosecond bigint. Both reads are monotonically clamped per instance:

ts
import {
  Clock,
  RealTimeClockProvider,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';

// --- RealTimeClockProvider ---

const realProvider = RealTimeClockProvider.create();
const realClock = Clock.create(realProvider);

const nowA = realClock.now();
const nowB = realClock.now();

console.log(`RealTimeClockProvider.now(): first=${nowA}, second=${nowB}`);

const hrtA = realClock.hrtime();
const hrtB = realClock.hrtime();

console.log(`RealTimeClockProvider.hrtime(): first=${hrtA}n, second=${hrtB}n`);

// --- VirtualTimeCounter + VirtualClockProvider ---

const counter = VirtualTimeCounter.create();
const virtualProvider = VirtualClockProvider.create(counter);
const virtualClock = Clock.create(virtualProvider);

const startMs = virtualClock.now();
console.log(`VirtualClock.now() at start: ${startMs}`);

counter.advance(500);
const after500 = virtualClock.now();
console.log(`VirtualClock.now() after advance(500): ${after500}`);

counter.advance(1000);
const after1000 = virtualClock.now();
console.log(`VirtualClock.now() after advance(1000): ${after1000}`);

Subpath exports

SubpathContents
@studnicky/clockClock, RealTimeClockProvider, VirtualClockProvider, VirtualTimeCounter
@studnicky/clock/interfacesClockProviderType

Virtual time control

VirtualTimeCounter and VirtualClockProvider give deterministic time control with no sleeping and no wall-clock dependency. Multiple independent or shared counters can drive separate clocks:

ts
import {
  Clock,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';
import { VirtualTimeFixture } from './fixtures/VirtualTimeFixture.js';

// --- hrtime matches epoch-ms * 1_000_000n ---

const counterA = VirtualTimeCounter.create();
const clockA = Clock.create(VirtualClockProvider.create(counterA));

counterA.advance(100);

const epochMs = clockA.now();
const ns = clockA.hrtime();

console.log(`epochMs=${epochMs}, hrtime=${ns}n (== ${epochMs} * 1_000_000n)`);

// --- Monotonicity after advances ---

const counterB = VirtualTimeCounter.create();
const clockB = Clock.create(VirtualClockProvider.create(counterB));

const readings: number[] = [];

const deltasLen = VirtualTimeFixture.Deltas.length;

for (let i = 0; i < deltasLen; i++) {
  counterB.advance(VirtualTimeFixture.Deltas[i] ?? 0);
  readings.push(clockB.now());
}

console.log(`monotonicity sequence: ${readings.join(' → ')}`);

// --- Two independent counters evolve independently ---

const counterX = VirtualTimeCounter.create({ 'startMs': 1000 });
const counterY = VirtualTimeCounter.create({ 'startMs': 2000 });
const clockX = Clock.create(VirtualClockProvider.create(counterX));
const clockY = Clock.create(VirtualClockProvider.create(counterY));

counterX.advance(500);
counterY.advance(100);

console.log(`independent counters: clockX.now()=${clockX.now()}, clockY.now()=${clockY.now()}`);

// --- Shared counter keeps multiple clocks in sync ---

const shared = VirtualTimeCounter.create();
const clockP = Clock.create(VirtualClockProvider.create(shared));
const clockQ = Clock.create(VirtualClockProvider.create(shared));

shared.advance(300);

console.log(`shared counter: clockP.now()=${clockP.now()}, clockQ.now()=${clockQ.now()}`);

Custom providers

Implement ClockProviderType (two methods: now(): number and hrtime(): bigint) to inject any time source into Clock. Swapping the provider changes what Clock returns without touching consumers:

ts
import type { ClockProviderType } from '../src/index.js';

import { Clock } from '../src/index.js';

// --- Custom provider with fixed values ---

class FixedClockProvider implements ClockProviderType {
  readonly #epochMs: number;
  readonly #ns: bigint;

  public constructor(epochMs: number, ns: bigint) {
    this.#epochMs = epochMs;
    this.#ns = ns;
  }

  public hrtime(): bigint {
    return this.#ns;
  }

  public now(): number {
    return this.#epochMs;
  }
}

const fixedProvider = new FixedClockProvider(9999, 9_999_000_000n);
const clockFixed = Clock.create(fixedProvider);

console.log(`FixedClockProvider: now()=${clockFixed.now()}, hrtime()=${clockFixed.hrtime()}n`);

// --- Swapping provider changes what Clock returns ---

class CountingClockProvider implements ClockProviderType {
  #callCount: number;

  public constructor() {
    this.#callCount = 0;
  }

  public hrtime(): bigint {
    this.#callCount += 1;
    return BigInt(this.#callCount) * 1_000_000n;
  }

  public now(): number {
    this.#callCount += 1;
    return this.#callCount;
  }

  public get callCount(): number {
    return this.#callCount;
  }
}

const countingProvider = new CountingClockProvider();
const clockCounting = Clock.create(countingProvider);

const firstNow = clockCounting.now();
const secondNow = clockCounting.now();

console.log(`CountingClockProvider: firstNow=${firstNow}, secondNow=${secondNow}`);

// --- DI seam: same Clock constructor, different behavior per provider ---

const clockA = Clock.create(new FixedClockProvider(1, 1_000_000n));
const clockB = Clock.create(new FixedClockProvider(2, 2_000_000n));

console.log(`DI swap: clockA.now()=${clockA.now()}, clockB.now()=${clockB.now()}`);

Observability hooks

Every stateful operation across Clock, RealTimeClockProvider, VirtualClockProvider, and VirtualTimeCounter exposes a protected lifecycle hook. Subclass any of these classes and override the relevant hook to add logging, metrics, or tracing without touching public API behavior.

HookClassWhen it firesArgs
onNow(timestamp)ClockAfter each now() call, with the monotonically-clamped epoch-ms returned to the callertimestamp: number
onHrtime(value)ClockAfter each hrtime() call, with the monotonically-clamped nanosecond bigint returned to the callervalue: bigint
onNow(timestamp)RealTimeClockProviderAfter each now() call, with the final epoch-ms (raw + offset) returned to the callertimestamp: number
onHrtime(value)RealTimeClockProviderAfter each hrtime() call, with the final nanosecond bigint (performance.now() + offset) returned to the callervalue: bigint
onNow(timestamp)VirtualClockProviderAfter each now() call, with the virtual epoch-ms (clamped to 0 if negative) returned to the callertimestamp: number
onHrtime(value)VirtualClockProviderAfter each hrtime() call, with the virtual nanosecond bigint returned to the callervalue: bigint
onAdvance(deltaMs, nowMs)VirtualTimeCounterAfter each positive advance() call, with the applied delta and the resulting epoch-msdeltaMs: number, nowMs: number
onNowMs(value)VirtualTimeCounterAfter each nowMs() call, with the current virtual epoch-ms returned to the callervalue: number
ts
import {
  Clock,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';

class TracedCounter extends VirtualTimeCounter {
  public constructor(options: Parameters<typeof VirtualTimeCounter.create>[0] = {}) {
    super(options ?? {});
  }

  protected override onAdvance(deltaMs: number, nowMs: number): void {
    console.log(`[clock] counter.advance  delta=${deltaMs}ms  nowMs=${nowMs}ms`);
  }

  protected override onNowMs(value: number): void {
    console.log(`[clock] counter.nowMs    value=${value}ms`);
  }
}

class TracedVirtualProvider extends VirtualClockProvider {
  public constructor(counter: Readonly<VirtualTimeCounter>) {
    super(counter);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] provider.now     timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] provider.hrtime  value=${value}ns`);
  }
}

class TracedClock extends Clock {
  public constructor(provider: VirtualClockProvider) {
    super(provider);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] clock.now        timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] clock.hrtime     value=${value}ns`);
  }
}

class ObservedClockExample {
  static run(): void {
    // Capture events for assertion
    const advanceEvents: { 'deltaMs': number; 'nowMs': number }[] = [];
    const nowMsEvents: number[] = [];
    const providerNowEvents: number[] = [];
    const clockNowEvents: number[] = [];
    const clockHrtimeEvents: bigint[] = [];

    class RecordingCounter extends TracedCounter {
      protected override onAdvance(deltaMs: number, nowMs: number): void {
        super.onAdvance(deltaMs, nowMs);
        advanceEvents.push({ 'deltaMs': deltaMs, 'nowMs': nowMs });
      }

      protected override onNowMs(value: number): void {
        super.onNowMs(value);
        nowMsEvents.push(value);
      }
    }

    class RecordingProvider extends TracedVirtualProvider {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        providerNowEvents.push(timestamp);
      }
    }

    class RecordingClock extends TracedClock {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        clockNowEvents.push(timestamp);
      }

      protected override onHrtime(value: bigint): void {
        super.onHrtime(value);
        clockHrtimeEvents.push(value);
      }
    }

    console.log('--- scenario: advance then read ---');

    const counter = new RecordingCounter({ 'startMs': 1000 });
    const provider = new RecordingProvider(counter);
    const clock = new RecordingClock(provider);

    const t0 = clock.now();
    counter.advance(500);
    const t1 = clock.now();
    counter.advance(250);
    const t2 = clock.now();
    const h0 = clock.hrtime();

    console.log(`\nReturned values: t0=${t0} t1=${t1} t2=${t2} h0=${h0}n`);

    // Assertions
    assert.equal(clockNowEvents.length, 3, 'clock.onNow fires 3 times');
    assert.equal(clockNowEvents[0], 1000, 'first now is 1000');
    assert.equal(clockNowEvents[1], 1500, 'second now is 1500 after +500');
    assert.equal(clockNowEvents[2], 1750, 'third now is 1750 after +250');

    assert.equal(clockHrtimeEvents.length, 1, 'clock.onHrtime fires once');
    assert.equal(clockHrtimeEvents[0], 1750n * 1_000_000n, 'hrtime matches final nowMs in ns');

    assert.equal(advanceEvents.length, 2, 'counter.onAdvance fires twice');
    assert.equal(advanceEvents[0]!.deltaMs, 500);
    assert.equal(advanceEvents[0]!.nowMs, 1500);
    assert.equal(advanceEvents[1]!.deltaMs, 250);
    assert.equal(advanceEvents[1]!.nowMs, 1750);

    assert.equal(providerNowEvents.length, 3, 'provider.onNow fires 3 times');

    console.log('\nobservedClock: all assertions passed');
  }
}

ObservedClockExample.run();

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Extending

Clock exposes two protected read hooks that subclasses may override to intercept or replace values before monotonicity clamping:

typescript
import { Clock } from '@studnicky/clock';
import type { ClockProviderType } from '@studnicky/clock/interfaces';

class TrackedClock extends Clock {
  constructor(provider: ClockProviderType) {
    super(provider);
  }

  protected override readHrtime(): bigint {
    const t = super.readHrtime();
    metrics.gauge('clock.hrtime', Number(t));
    return t;
  }
}

Try it

The examples below run directly in the browser against the published package.

Builder

Watch the builder chain assemble a virtual clock — each step is logged.

Clock builder
/** builder-clock — construct Clock and VirtualClockProvider via the fluent builder API. Run: npx tsx packages/clock/examples/builder-clock.ts */
import assert from 'node:assert/strict';

import { Clock, VirtualClockProvider, VirtualTimeCounter } from '../src/index.js';

// #region usage
// Build a VirtualTimeCounter starting at epoch 1_000 ms.
const counter = VirtualTimeCounter.builder()
  .withStartMs(1_000)
  .build();

console.log('counter initial nowMs:', counter.nowMs());

// Build a VirtualClockProvider backed by that counter.
const provider = VirtualClockProvider.builder()
  .withCounter(counter)
  .build();

console.log('provider initial now():', provider.now());

// Build a Clock that delegates to the provider.
const clock = Clock.builder()
  .withProvider(provider)
  .build();

console.log('clock initial now():', clock.now());

// Advance the counter by 500 ms — Clock and provider see the change immediately.
counter.advance(500);
console.log('after advance(500) — counter.nowMs():', counter.nowMs());
console.log('after advance(500) — clock.now():', clock.now());
// #endregion usage

assert.equal(counter.nowMs(), 1_500);
assert.equal(provider.now(), 1_500);
assert.equal(clock.now(), 1_500);

console.log('builder-clock: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

Each now() and hrtime() read fires the corresponding hook on the counter, provider, and clock layers.

Clock lifecycle hooks
/** observedClock — trace every now/hrtime/advance read via protected hook overrides. Run: npx tsx examples/observedClock.ts */

import assert from 'node:assert/strict';

// #region usage
import {
  Clock,
  VirtualClockProvider,
  VirtualTimeCounter
} from '../src/index.js';

class TracedCounter extends VirtualTimeCounter {
  public constructor(options: Parameters<typeof VirtualTimeCounter.create>[0] = {}) {
    super(options ?? {});
  }

  protected override onAdvance(deltaMs: number, nowMs: number): void {
    console.log(`[clock] counter.advance  delta=${deltaMs}ms  nowMs=${nowMs}ms`);
  }

  protected override onNowMs(value: number): void {
    console.log(`[clock] counter.nowMs    value=${value}ms`);
  }
}

class TracedVirtualProvider extends VirtualClockProvider {
  public constructor(counter: Readonly<VirtualTimeCounter>) {
    super(counter);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] provider.now     timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] provider.hrtime  value=${value}ns`);
  }
}

class TracedClock extends Clock {
  public constructor(provider: VirtualClockProvider) {
    super(provider);
  }

  protected override onNow(timestamp: number): void {
    console.log(`[clock] clock.now        timestamp=${timestamp}ms`);
  }

  protected override onHrtime(value: bigint): void {
    console.log(`[clock] clock.hrtime     value=${value}ns`);
  }
}

class ObservedClockExample {
  static run(): void {
    // Capture events for assertion
    const advanceEvents: { 'deltaMs': number; 'nowMs': number }[] = [];
    const nowMsEvents: number[] = [];
    const providerNowEvents: number[] = [];
    const clockNowEvents: number[] = [];
    const clockHrtimeEvents: bigint[] = [];

    class RecordingCounter extends TracedCounter {
      protected override onAdvance(deltaMs: number, nowMs: number): void {
        super.onAdvance(deltaMs, nowMs);
        advanceEvents.push({ 'deltaMs': deltaMs, 'nowMs': nowMs });
      }

      protected override onNowMs(value: number): void {
        super.onNowMs(value);
        nowMsEvents.push(value);
      }
    }

    class RecordingProvider extends TracedVirtualProvider {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        providerNowEvents.push(timestamp);
      }
    }

    class RecordingClock extends TracedClock {
      protected override onNow(timestamp: number): void {
        super.onNow(timestamp);
        clockNowEvents.push(timestamp);
      }

      protected override onHrtime(value: bigint): void {
        super.onHrtime(value);
        clockHrtimeEvents.push(value);
      }
    }

    console.log('--- scenario: advance then read ---');

    const counter = new RecordingCounter({ 'startMs': 1000 });
    const provider = new RecordingProvider(counter);
    const clock = new RecordingClock(provider);

    const t0 = clock.now();
    counter.advance(500);
    const t1 = clock.now();
    counter.advance(250);
    const t2 = clock.now();
    const h0 = clock.hrtime();

    console.log(`\nReturned values: t0=${t0} t1=${t1} t2=${t2} h0=${h0}n`);

    // Assertions
    assert.equal(clockNowEvents.length, 3, 'clock.onNow fires 3 times');
    assert.equal(clockNowEvents[0], 1000, 'first now is 1000');
    assert.equal(clockNowEvents[1], 1500, 'second now is 1500 after +500');
    assert.equal(clockNowEvents[2], 1750, 'third now is 1750 after +250');

    assert.equal(clockHrtimeEvents.length, 1, 'clock.onHrtime fires once');
    assert.equal(clockHrtimeEvents[0], 1750n * 1_000_000n, 'hrtime matches final nowMs in ns');

    assert.equal(advanceEvents.length, 2, 'counter.onAdvance fires twice');
    assert.equal(advanceEvents[0]!.deltaMs, 500);
    assert.equal(advanceEvents[0]!.nowMs, 1500);
    assert.equal(advanceEvents[1]!.deltaMs, 250);
    assert.equal(advanceEvents[1]!.nowMs, 1750);

    assert.equal(providerNowEvents.length, 3, 'provider.onNow fires 3 times');

    console.log('\nobservedClock: all assertions passed');
  }
}

ObservedClockExample.run();
// #endregion usage
Output
Press Execute to run this example against the real library.

Source on GitHub