Skip to content

Convert to Typescript #513

Description

@DavidWells

Ok we want to convert code to full on typescript

This should also include generic types to dynamically support plugin methods and custom events etc

Can you draft up a plan for me on this please

thanks @claude opus

Activity

  1. github-actions commented on May 2, 2026

    @github-actions

    📋 Claude Code execution complete ✅

    ℹ️ Analysis only (no code changes)

    📊 Execution Info: Issue #513 | Unknown (403 files) | @DavidWells
    ℹ️ Action Run: https://github.com/DavidWells/analytics/actions/runs/25242829112

    🚀 Automated management executed:

    • ✅ Created: 2026-May
  2. github-actions commented on May 2, 2026

    @github-actions

    Claude finished @DavidWells's task in 3m 22s —— View job


    TypeScript Migration Plan

    • Explore repo structure (monorepo layout, build tooling, test setup)
    • Review core API surface (packages/analytics-core)
    • Review plugin contract & lifecycle event flow
    • Inspect existing JSDoc/types pipeline + already-typed util packages
    • Draft full migration plan

    Where the codebase is today

    • pnpm + turborepo + lerna monorepo, ~50 packages: analytics, analytics-core, analytics-utils, ~30 plugins (analytics-plugin-*), ~15 utils (analytics-util-*), plus gatsby-plugin-analytics and analytics-cli.
    • Build: microbundle per-package. Core has split client/ and server/ builds; everything else is a single bundle.
    • Tests: uvu (newer packages) and ava (legacy at root).
    • Types today are produced by an unusual two-step pipeline in packages/analytics-core/scripts/types.cjs:1:
      1. tsd-jsdoc walks JSDoc in src/ and emits _temp-types/types.d.ts.
      2. The script then regex-rewrites the output — converting type AnalyticsInstance = to interface, splicing <T> generics into *Hook / *Context / *ContextProps types, renaming AnalyticsPlugin → AnalyticsPluginBase and re-exporting it as AnalyticsPlugin<T extends string = string>, etc. — to ship dist/types.d.ts.
    • A few util packages already use TypeScript-via-allowJs: analytics-util-types, analytics-utils, analytics-util-listener, analytics-util-activity, analytics-util-url, analytics-util-control-flow, analytics-util-visitor-source. Each has a minimal tsconfig.json with allowJs: true + emitDeclarationOnly: true. No .ts source files exist yet — these still author in JS and only emit declarations.
    • Plugin contract lives in two places: a tiny JSDoc @typedef in packages/analytics-core/src/pluginTypeDef.js:1 (just name, EVENTS, config, initialize, page, track, identify, loaded, ready) and the runtime parser in packages/analytics-core/src/index.js:65 which treats any plugin key that isn't in nonEvents = ['name', 'EVENTS', 'config', 'loaded'] as a hook handler. So plugins can register arbitrary lifecycle and custom event handlers.
    • Core lifecycle events are listed in packages/analytics-core/src/events.js:3 (~30 events: bootstrap, page/pageStart/pageEnd/pageAborted, track*, identify*, reset*, setItem*/removeItem*, online/offline, plus dynamic registerPlugin:<name> and ready:<name>).
    • Plugins export a factory that returns a plain object of method handlers receiving { payload, config, instance, plugins, abort } — see packages/analytics-plugin-google-analytics/src/browser.js:81.

    The regex-rewrite type pipeline is brittle, tells the real story: David already wants generics on plugins, hooks, and payloads — the current setup is a workaround for not having TS source. A real migration replaces this with first-class .ts.


    Goals

    1. Author packages in TypeScript directly (no more JSDoc → tsd-jsdoc → regex).
    2. Ship strong types with generics so consumers can:
      • Declare a custom event map and get analytics.track('MyEvent', { ... }) autocomplete + payload checking.
      • Declare custom plugin methods and get analytics.plugins.myPlugin.doThing() typed.
      • Get typed payload in .on('track', ({ payload }) => ...) based on the event name.
    3. Keep zero‑runtime‑change guarantee — types only, no behavioral churn.
    4. Stay backwards compatible for JS consumers and existing plugin authors.
    5. Migrate incrementally, package‑by‑package, with green tests at every step.

    Proposed phased plan

    Phase 0 — Foundations (1 PR, no source migration)

    • Add a root tsconfig.base.json with: target: ES2019, module: ESNext, moduleResolution: Bundler, strict: true, declaration: true, declarationMap: true, sourceMap: true, isolatedModules: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true.
    • Bump typescript from ^4.3.5 (root) to ^5.4 so we can use satisfies, const type params, and Bundler resolution — all useful for the generic plugin system.
    • Add TS Project References so analytics-core depends on analytics-utils, etc. — enables tsc --build for fast incremental type-check across the workspace.
    • Replace microbundle for .ts packages with tsup (esbuild-based, supports CJS+ESM+.d.ts in one shot, watch mode). Keep microbundle for any package not yet migrated. Both can coexist during the rollout.
    • Add a typecheck turbo task wired into CI.

    Phase 1 — Define the public type surface in a new package

    Create packages/analytics-types (or fold into existing @analytics/type-utils) containing the canonical .ts definitions for everything dist/types.d.ts ships today, but rewritten as native generics:

    // Core event registry — extensible via module augmentation
    export interface CoreEventMap {
      bootstrap: { params: Record<string, string>; user: User; persistedUser: User; initialUser: Partial<User> }
      page: { properties: PageData; options: EventOptions; userId: string | null; anonymousId: string }
      track: { event: string; properties: Record<string, unknown>; options: EventOptions; userId: string | null; anonymousId: string }
      identify: { userId: string | null; traits: Record<string, unknown>; options: EventOptions; anonymousId: string; previousId?: string }
      // ...full list mirrored from src/events.js
    }
    
    // Allow user augmentation: declare module '@analytics/types' { interface CoreEventMap { 'cart:abandoned': { items: string[] } } }
    
    export type EventName = keyof CoreEventMap
    export type PayloadOf<E extends EventName> = CoreEventMap[E]
    
    export interface HookContext<E extends EventName, Cfg = Record<string, unknown>> {
      payload: CoreEventMap[E]
      config: Cfg
      instance: AnalyticsInstance
      plugins: PluginsState
      abort: (reason?: string) => void
    }
    
    // Plugin definition is generic over its config and the methods it adds
    export interface AnalyticsPlugin<
      Name extends string = string,
      Cfg = Record<string, unknown>,
      Methods extends Record<string, (...a: any[]) => any> = {},
      Events extends Record<string, string> = {},
    > {
      name: Name
      EVENTS?: Events
      config?: Cfg
      enabled?: boolean
      loaded?: (ctx: { config: Cfg }) => boolean
      initialize?: (ctx: HookContext<'initialize', Cfg>) => void | Promise<void>
      page?: (ctx: HookContext<'page', Cfg>) => void | Promise<void>
      track?: (ctx: HookContext<'track', Cfg>) => void | Promise<void>
      identify?: (ctx: HookContext<'identify', Cfg>) => void | Promise<void>
      ready?: (ctx: HookContext<'ready', Cfg>) => void | Promise<void>
      reset?: (ctx: HookContext<'reset', Cfg>) => void | Promise<void>
      methods?: Methods
      // open-ended so plugins can hook custom events
      [hook: string]: unknown
    }

    The instance becomes generic over the union of plugins so custom methods land on analytics.plugins:

    type PluginMethodsOf<P> = P extends AnalyticsPlugin<infer N, any, infer M, any>
      ? { [K in N]: M }
      : never
    type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
    
    export interface AnalyticsInstance<P extends readonly AnalyticsPlugin[] = []> {
      identify: (...args: IdentifyArgs) => Promise<void>
      track<E extends EventName>(event: E, payload?: PayloadOf<E>, options?: EventOptions, callback?: Callback): Promise<void>
      page: (...args: PageArgs) => Promise<void>
      on<E extends EventName>(event: E, cb: (ctx: HookContext<E>) => void): DetachListener
      on(event: '*', cb: (ctx: HookContext<EventName>) => void): DetachListener
      once<E extends EventName>(event: E, cb: (ctx: HookContext<E>) => void): DetachListener
      plugins: PluginsApi & UnionToIntersection<PluginMethodsOf<P[number]>>
      // ...
    }
    
    export function Analytics<const P extends readonly AnalyticsPlugin[]>(
      config: AnalyticsConfig & { plugins?: P }
    ): AnalyticsInstance<P>

    This phase is types only, shippable independently — JS consumers get smarter .d.ts immediately, and plugin authors can start targeting the new shapes. Land it before any source rewrites.

    Phase 2 — Migrate analytics-utils and the small util packages (low risk)

    • Convert each .js → .ts one util at a time. Most of these are 1–3 files.
    • Drop allowJs: true; switch builds to tsup.
    • Verify the existing uvu tests still run (uvu supports .ts via tsm or tsx loader — add tsm as devDep).

    Order: analytics-util-types, analytics-utils, analytics-util-storage, analytics-util-listener, analytics-util-activity, analytics-util-url, analytics-util-params, analytics-util-queue, analytics-util-router, then the rest.

    Phase 3 — Migrate analytics-core (the big one)

    • Convert src/index.js (1,032 lines) and the modules/, middleware/, utils/ subtrees to .ts.
    • Delete scripts/types.cjs and the tsd-jsdoc regex pipeline. tsc now emits the declarations directly.
    • Internally type the redux-style store actions as a discriminated union on action.type keyed by CoreEventMap — this gives compile-time exhaustiveness on every reducer and middleware.
    • Keep the client/ and server/ build split. The TS source is the same; only the bundler defines (BROWSER, SERVER, NODE_ENV) differ. tsup handles that with define.
    • Tests in packages/analytics-core/tests/ stay as .js for now — they're integration tests against the public API, no need to convert. (Optional follow-up.)

    Phase 4 — Migrate plugins

    • Plugins are independent — convert in any order, or open it up to community PRs after Phase 3 lands.

    • Each plugin gets a typed Config interface and uses defineAnalyticsPlugin<Config, Methods, Events>({...}) (a tiny helper that just returns its argument but pins generics):

      export const googleAnalytics = (config: GAConfig) => defineAnalyticsPlugin({
        name: 'google-analytics' as const,
        config,
        initialize: ({ config }) => { /* ... */ },
        page: ({ payload, config, instance }) => { /* payload.properties is typed */ },
      })
    • Suggested order: ones already using TypeScript-flavored utils first (analytics-plugin-do-not-track, analytics-plugin-tab-events, analytics-plugin-window-events), then the heavier ones (google-analytics, segment, mixpanel, hubspot, intercom, fullstory, etc.).

    Phase 5 — Migrate the wrapper packages and tooling

    • packages/analytics/src/index.js is 16 lines — trivial.
    • analytics-cli, gatsby-plugin-analytics: convert and ship typed.
    • Update examples/ README snippets to show the generic event-map augmentation pattern.

    Phase 6 — Cleanup

    • Remove tsd-jsdoc, dox, doxxx, jsdoc, jsdoc-to-markdown from devDeps if doc generation can switch to typedoc (recommended — reads .ts natively).
    • Site (site/) docs generation likely consumes the JSDoc output; rewire to typedoc --json or keep the existing JSDoc comments above each TS export (TypeDoc reads both).
    • Drop babel config if unused after migration.

    What "generic types to dynamically support plugin methods and custom events" looks like for users

    // User project
    import { Analytics } from 'analytics'
    import googleAnalytics from '@analytics/google-analytics'
    import customerIo from '@analytics/customerio'
    
    // 1. Augment the event map with custom events
    declare module '@analytics/types' {
      interface CoreEventMap {
        'cart:abandoned': { items: string[]; cartValue: number }
        'video:watched': { videoId: string; durationMs: number }
      }
    }
    
    const analytics = Analytics({
      app: 'my-app',
      plugins: [googleAnalytics({ measurementIds: ['G-X'] }), customerIo({ siteId: 'abc' })] as const,
    })
    
    analytics.track('cart:abandoned', { items: ['a'], cartValue: 99 })  // ✓ typed
    analytics.track('cart:abandoned', { items: ['a'] })                  // ✗ missing cartValue
    analytics.track('unknown:event', {})                                 // ✗ unless map is augmented
    
    analytics.on('video:watched', ({ payload }) => {
      payload.durationMs.toFixed(2)  // ✓ typed
    })
    
    // 2. Plugin methods land on .plugins.<name>
    analytics.plugins['customerio'].identifyAnonymous(...)  // ✓ typed (if customerIo declares methods)

    The two extension points are module augmentation (for events, opt-in, no breaking change for existing users) and as const plugin tuples (for plugin methods, inferred automatically).


    Risks & open questions

    • Bundle size: tsup/esbuild output is competitive with microbundle for ESM but the legacy UMD/IIFE outputs (used by analytics-core/client/build:standalone) need verification. May need to keep rollup for the CDN bundle.
    • Backward compat for JS plugin authors: the runtime accepts [hook: string]: unknown on a plugin, so existing JS plugins keep working — only the types tighten. Worth documenting.
    • AnalyticsInstance<P> ergonomics: consumers who store the instance in a non-inferred location (e.g. React context) will need ReturnType<typeof Analytics<typeof plugins>> or a helper alias. Worth shipping a type AnalyticsClient<P> = ... alias and doc snippet.
    • Doc pipeline: the current site reads JSDoc-derived markdown. Decide upfront: keep JSDoc comments on TS exports + jsdoc-to-markdown, or move to TypeDoc. (My recommendation: TypeDoc; outputs richer docs and avoids dual-sourcing.)
    • Test runner: uvu works with TS via tsm. The legacy root ava config (packages/**/*.test.js) should be retired once everything's on uvu.
    • Estimated scope: Phase 0–1 is ~1–2 weeks elapsed; Phase 2 (~15 utils) is mechanical, ~1 week; Phase 3 (analytics-core) is the hard one — 2–3 weeks for the rewrite + verifying both client and server builds + ensuring the discriminated-union action types don't regress runtime; Phase 4 (~30 plugins) is parallelizable and could absorb community help over a release cycle.

    Want me to start with Phase 0 (root tsconfig.base.json, TS bump to 5.4, project references skeleton) as the first concrete PR? That's the smallest change that unblocks everything else and is safe to land independently.
    • Branch: claude/issue-513-20260502-0335

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto-generatedAutomatically generated contentclaude-codeItems created or modified by Claude Code

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions