Why monkey-patch the global clock to test your code? Why rewrite the same time interface to inject it?
What if that interface doubled as your fake timers? What if it were also type-safe and thoroughly tested?
Time-Provider is that interface: ready to use, fully deterministic in tests, and several times faster than Jest or Sinon fake timers. Bring your own date library, and extend it with addons!
Time is scoped per call site: no global patch, no restore or cleanup step.
Compare it to Jest fake timers or Compare it to Sinon fake timers.
Time-Provider makes time an explicit, injectable dependency: a single object exposing a clock, a converter, a scheduler, and timings, swappable per call site.
Note: Non baseline or additional features are available as addons.
- Four clock strategies: system, manual, sequential and fixed - same API for production and tests.
- Time flow and wall clock steps: make time flow, simulate a host sleep or a wall-clock correction. Also step from timer to timer.
- Deterministic timers: driven by the clock strategy and run synchronously.
- Bring your own date library: multiple date library plugins are available. Your code keeps working with the date type it already uses.
- Timezone support: backed by the underlying library of your choice.
- Tree-shakable: no deterministic runtimes bundled when not imported.
- Zero runtime dependencies in
@time-provider/core.
npm install @time-provider/core @time-provider/plugin-nativeSwap plugin-native for plugin-dayjs, plugin-luxon, plugin-moment, plugin-moment-timezone, or plugin-temporal depending on the date library you use.
// PROD: import the default production runtime
import { createTimeProvider } from "@time-provider/core";
import { plugin } from "@time-provider/plugin-native";
// create a production runtime
const timeProvider = createTimeProvider.for(plugin).create();
class UserService {
constructor(private readonly timeProvider: ITimeProvider<Date>) {}
createUser() {
return { createdAt: this.timeProvider.clock.utcNow() };
}
}// TEST: import the deterministic runtime
import { createTimeProvider } from "@time-provider/core/deterministic";
import { plugin } from "@time-provider/plugin-native/deterministic";
// create a deterministic runtime
it("sets the creation date to now", () => {
const now = "2026-01-01T00:00:00.000Z";
using timeProvider = createTimeProvider.for(plugin).asManual().withInitialTime(now).create();
expect(new UserService(timeProvider).createUser().createdAt).toEqual(new Date(now));
});Similar idea for timers, closer to a real service - a retry/backoff loop injected with the time provider, unaware of which strategy backs it:
// PROD: a retry/backoff service, injected with the real time provider
class RetryingOperation {
constructor(private readonly timeProvider: ITimeProvider<Date>) {}
run(operation: () => boolean, onGiveUp: () => void, maxAttempts = 3) {
let attempt = 0;
this.timeProvider.scheduler.timers.recurring(() => {
attempt++;
if (operation()) return false; // succeeded, stop retrying
if (attempt >= maxAttempts) {
onGiveUp();
return false;
}
return { seconds: attempt }; // failed, backoff: 1s, 2s, 3s...
});
}
}
new RetryingOperation(timeProvider).run(sendRequest, pageOnCallEngineer);// TEST: same service, injected with a manual provider instead
describe("RetryingOperation ~ run", () => {
it("retries until it succeeds", () => {
using timeProvider = createTimeProvider.for(plugin).asManual().create();
let attempts = 0;
let gaveUp = false;
new RetryingOperation(timeProvider).run(
() => ++attempts === 3, // succeeds on the 3rd try
() => (gaveUp = true),
);
timeProvider.clock.moveBy({ seconds: 1 }); // 2nd attempt
timeProvider.clock.moveBy({ seconds: 2 }); // 3rd attempt, succeeds
expect(attempts).toBe(3);
expect(gaveUp).toBe(false);
});
});| Strategy | Behavior | Typical use | Note |
|---|---|---|---|
| System | Real time, real timers | Production | |
| Fixed | Always the same instant | Deterministic frozen single-instant tests | Timers never fire |
| Manual | Advances only when told to | Simulations, timer/retry logic tests | |
| Sequential | Returns a predefined instant sequence | Tests asserting on changing timestamps | Time reads move to the next instant |
createTimeProvider.for(plugin).asFixed().withFixedTime("2026-01-01T00:00Z").create();
createTimeProvider.for(plugin).asManual().withInitialTime("2026-01-01T00:00Z").create();
createTimeProvider
.for(plugin)
.asSequential()
.withSequentialTime("2026-01-01T00:01Z")
.withSequentialTime(1767225720000)
.create();Manual and sequential clocks run synchronously. A due timer callback fires in-line, as a direct side effect of the call that made it due (and not on a real event-loop tick). This is what makes them deterministic without
await, but it also means call ordering can differ subtly from a real async run.
Within the scope of this library, these two terms refer to different concepts.
- A plugin is essentially an adapter. It allows you to connect your preferred date library to the Time-Provider core library without adding any new functionality. Its sole purpose is to bridge the two libraries.
- An addon, as the name suggests, extends the library by introducing new functionality or enhancing existing facades.
- Animation-frame API addon - access browser-specific animation frame timers
- Compat addon - keep calling native-style setTimeout/setInterval/queueMicrotask/performance while you migrate
- Cron addon - schedule recurring callbacks with the cron syntax or a JSON-friendlier one
- ETA addon - get the ETA (estimated time of arrival) for a task by notifying its progression
- Idle addon - run callbacks when the host reports itself idle, drained on demand on a deterministic clock
- Day.js plugin - use Day.js Dayjs objects
- Luxon plugin - use Luxon DateTime objects
- Moment.js plugin - use Moment.js Moment objects
- Moment.js (with timezones) plugin - use Moment.js Moment objects with local time or time zone support
- Native JS Date plugin - use native JS Date objects
- Temporal plugin - use Temporal ZonedDateTime objects
Every time provider exposes the same four-part surface:
interface ITimeProvider<TDate> extends IHasAbortSignal, IDisposable {
clock: IClock<TDate>; // localNow, utcNow, timestampNow, monotonicNow, withTimezone
converter: IConverter<TDate>; // convertToUtc, convertToLocal
scheduler: IScheduler; // timers (once, every, recurring, wait), microtasks
timings: ITimings; // mark, measure, entries, clear
}clock gives the current time: as a timestamp, as a monotonic value, or as a UTC or local date in your date library's type.
converter turns a string, a timestamp or a date into a UTC (or local) date of your date library's type.
scheduler is where everything that schedules a callback to run later sits, timers included.
timings holds performance marks and measures. Monotonic time and its origin stay on clock.
- Guide - Read the guide
- API - Browse the library API
- ARCHITECTURE.md - how the packages fit together, the plugin/adapter model, why native
Dateand plain Moment.js are UTC-only. - CONTRIBUTING.md - development setup, workflow, reporting bugs/features.
- BENCHMARK.md - faster than Jest/Sinon fake timers.
- Per-package README (
packages/<name>/README.md) for adapter-specific notes. - CHANGELOG.md - changes log from the core library and all plugins.