Skip to content

Writing a Custom Plugin

Every plugin needs the same three conversions between its date library's value and time-provider's internal timestamp representation. core declares that shape once, in ITimeConverter<TDate>:

ts
interface ITimeConverter<TDate> {
  convertToTimestamp(time: string | number | TDate): number;
  convertToUtcDate(time: string | number | TDate): TDate;
  convertToLocalDate(timezone: TimezoneDefinition, time: string | number | TDate): TDate;
}

Every runtime base class takes an ITimeConverter<TDate> in its constructor and implements the conversion hooks by delegating to it. Write it once as a RuntimeHelper class whose static methods match ITimeConverter's shape — a class value structurally satisfies the interface via its static side, so no wrapping instance is needed:

ts
export class RuntimeHelper {
  static convertToTimestamp(time: string | number | MyDate): number {
    /* ... */
  }
  static convertToUtcDate(time: string | number | MyDate): MyDate {
    /* ... */
  }
  static convertToLocalDate(timezone: TimezoneDefinition, time: string | number | MyDate): MyDate {
    /* ... */
  }
}

Two entry points, two plugin classes

Like core itself, a plugin is split into a system half and a deterministic half (see Mental Model) — each with its own base classes, imported from @time-provider/core and @time-provider/core/deterministic respectively.

System (index.ts) — one runtime, forwarding its constructor arguments plus RuntimeHelper into BaseSystemRuntime:

ts
import { BaseSystemPlugin, BaseSystemRuntime, type TimezoneDefinition } from "@time-provider/core";

class SystemRuntime extends BaseSystemRuntime<MyDate> {
  constructor(localTimezone: TimezoneDefinition) {
    super(localTimezone, RuntimeHelper);
  }
  localNow(): MyDate {
    /* ... */
  }
  utcNow(): MyDate {
    /* ... */
  }
  timestampNow(): number {
    /* ... */
  }
}

export class SystemPlugin extends BaseSystemPlugin<MyDate> {
  protected readonly SystemRuntimeCtor = SystemRuntime;
}

export const plugin: ISystemPlugin<MyDate> = new SystemPlugin();

Deterministic (deterministic.ts) — three runtimes (fixed, manual, sequential), each extending the matching Base*Runtime from @time-provider/core/deterministic. Only the manual one needs the advance* methods, since only it implements IAdvanceable:

ts
import {
  BaseDeterministicPlugin,
  BaseFixedRuntime,
  BaseManualRuntime,
  BaseSequentialRuntime,
} from "@time-provider/core/deterministic";

class FixedRuntime extends BaseFixedRuntime<MyDate> {
  constructor(localTimezone: TimezoneDefinition, fixedTime: string | number | MyDate) {
    super(localTimezone, fixedTime, RuntimeHelper);
  }
}

class SequentialRuntime extends BaseSequentialRuntime<MyDate> {
  constructor(localTimezone: TimezoneDefinition, sequentialTimes: (string | number | MyDate)[]) {
    super(localTimezone, sequentialTimes, RuntimeHelper);
  }
}

class ManualRuntime extends BaseManualRuntime<MyDate> {
  constructor(localTimezone: TimezoneDefinition, fixedTime: string | number | MyDate) {
    super(localTimezone, fixedTime, RuntimeHelper);
  }
  protected advanceYears(time: MyDate, years: number): MyDate {
    /* ... */
  }
  protected advanceMonths(time: MyDate, months: number): MyDate {
    /* ... */
  }
  protected advanceDays(time: MyDate, days: number): MyDate {
    /* ... */
  }
  protected advanceHours(time: MyDate, hours: number): MyDate {
    /* ... */
  }
  protected advanceMinutes(time: MyDate, minutes: number): MyDate {
    /* ... */
  }
  protected advanceSeconds(time: MyDate, seconds: number): MyDate {
    /* ... */
  }
  protected advanceMilliseconds(time: MyDate, milliseconds: number): MyDate {
    /* ... */
  }
}

export class DeterministicPlugin extends BaseDeterministicPlugin<MyDate> {
  protected readonly ManualRuntimeCtor = ManualRuntime;
  protected readonly FixedRuntimeCtor = FixedRuntime;
  protected readonly SequentialRuntimeCtor = SequentialRuntime;
}

export const plugin: IDeterministicPlugin<MyDate> = new DeterministicPlugin();

If your date library can't represent an arbitrary IANA timezone (like native Date or plain Moment.js), extend BaseUtcOnlySystemPlugin/ BaseUtcOnlyDeterministicPlugin instead — their runtime constructors drop the localTimezone parameter accordingly. See Mental Model for why the two hierarchies are kept separate.

Export each plugin singleton under the matching name, matching every built-in adapter, so it drops straight into the corresponding createTimeProvider.for(plugin).

For a complete worked example, read any of the built-in plugins' source — plugin-native is the shortest (UTC-only), plugin-dayjs the shortest full (timezone-aware) one — in the packages/ directory of the repository.