Repository navigation
Convert to Typescript #513
Description
Activity
📋 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
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-*), plusgatsby-plugin-analyticsandanalytics-cli. - Build:
microbundleper-package. Core has splitclient/andserver/builds; everything else is a single bundle. - Tests:
uvu(newer packages) andava(legacy at root). - Types today are produced by an unusual two-step pipeline in
packages/analytics-core/scripts/types.cjs:1:tsd-jsdocwalks JSDoc insrc/and emits_temp-types/types.d.ts.- The script then regex-rewrites the output — converting
type AnalyticsInstance =tointerface, splicing<T>generics into*Hook/*Context/*ContextPropstypes, renamingAnalyticsPlugin→AnalyticsPluginBaseand re-exporting it asAnalyticsPlugin<T extends string = string>, etc. — to shipdist/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 minimaltsconfig.jsonwithallowJs: true+emitDeclarationOnly: true. No.tssource files exist yet — these still author in JS and only emit declarations. - Plugin contract lives in two places: a tiny
JSDoc @typedefinpackages/analytics-core/src/pluginTypeDef.js:1(justname,EVENTS,config,initialize,page,track,identify,loaded,ready) and the runtime parser inpackages/analytics-core/src/index.js:65which treats any plugin key that isn't innonEvents = ['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 dynamicregisterPlugin:<name>andready:<name>). - Plugins export a factory that returns a plain object of method handlers receiving
{ payload, config, instance, plugins, abort }— seepackages/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
- Author packages in TypeScript directly (no more JSDoc → tsd-jsdoc → regex).
- 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
payloadin.on('track', ({ payload }) => ...)based on the event name.
- Declare a custom event map and get
- Keep zero‑runtime‑change guarantee — types only, no behavioral churn.
- Stay backwards compatible for JS consumers and existing plugin authors.
- 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.jsonwith: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
typescriptfrom^4.3.5(root) to^5.4so we can usesatisfies,consttype params, andBundlerresolution — all useful for the generic plugin system. - Add TS Project References so
analytics-coredepends onanalytics-utils, etc. — enablestsc --buildfor fast incremental type-check across the workspace. - Replace
microbundlefor.tspackages withtsup(esbuild-based, supports CJS+ESM+.d.tsin one shot, watch mode). Keepmicrobundlefor any package not yet migrated. Both can coexist during the rollout. - Add a
typecheckturbo 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.tsdefinitions for everythingdist/types.d.tsships 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.tsimmediately, and plugin authors can start targeting the new shapes. Land it before any source rewrites.Phase 2 — Migrate
analytics-utilsand the small util packages (low risk)- Convert each
.js→.tsone util at a time. Most of these are 1–3 files. - Drop
allowJs: true; switch builds totsup. - Verify the existing
uvutests still run (uvu supports.tsviatsmortsxloader — addtsmas 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 themodules/,middleware/,utils/subtrees to.ts. - Delete
scripts/types.cjsand thetsd-jsdocregex pipeline.tscnow emits the declarations directly. - Internally type the redux-style store actions as a discriminated union on
action.typekeyed byCoreEventMap— this gives compile-time exhaustiveness on every reducer and middleware. - Keep the
client/andserver/build split. The TS source is the same; only the bundler defines (BROWSER,SERVER,NODE_ENV) differ. tsup handles that withdefine. - Tests in
packages/analytics-core/tests/stay as.jsfor 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
Configinterface and usesdefineAnalyticsPlugin<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.jsis 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-markdownfrom devDeps if doc generation can switch totypedoc(recommended — reads.tsnatively). - Site (
site/) docs generation likely consumes the JSDoc output; rewire totypedoc --jsonor keep the existing JSDoc comments above each TS export (TypeDoc reads both). - Drop
babelconfig 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 constplugin tuples (for plugin methods, inferred automatically).
Risks & open questions
- Bundle size:
tsup/esbuild output is competitive withmicrobundlefor ESM but the legacy UMD/IIFE outputs (used byanalytics-core/client/build:standalone) need verification. May need to keeprollupfor the CDN bundle. - Backward compat for JS plugin authors: the runtime accepts
[hook: string]: unknownon 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 needReturnType<typeof Analytics<typeof plugins>>or a helper alias. Worth shipping atype 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:
uvuworks with TS viatsm. The legacy rootavaconfig (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- addedauto-generatedAutomatically generated contentAutomatically generated contentclaude-codeItems created or modified by Claude CodeItems created or modified by Claude Code
on May 2, 2026
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