Typed Event Tracking with TypeScript
Eliminate tracking bugs at compile time. This guide shows you how to build a type-safe analytics layer for Mixpanel, PostHog, or any analytics provider using TypeScript.
Why This Matters
Untyped analytics calls are the #1 source of data quality issues. A typo in an event name (user_signedup vs user_signed_up) creates a silent failure that only surfaces weeks later when a dashboard shows unexpected drops. Type-safe tracking catches these errors instantly in your IDE.
Naming Conventions
Consistent naming enables filtering, grouping, and automated documentation. Pick one convention and enforce it with types.
| Pattern | Examples | Use Case |
|---|---|---|
| {object}_{action} | dashboard_created, report_exported | Core product events |
| {object}_{action}_{result} | payment_submitted_success | Events with outcomes |
| page_viewed | page_viewed + page property | Page views (single event type) |
| feature_{action} | feature_enabled, feature_disabled | Feature flags and toggles |
Avoid These Anti-Patterns
- •
Click_Dashboard_Create— mixing cases, action-first - •
createDashboard— camelCase breaks analytics tools - •
Dashboard Created— spaces cause query issues - •
event_1,cta_click— vague, non-descriptive names
Required Properties
Every event should include base context. Define these once in your types and include them automatically in your tracking wrapper.
| Property | Type | Description |
|---|---|---|
| timestamp | ISO8601 | When the event occurred (auto-filled) |
| user_id | string | Authenticated user identifier |
| anonymous_id | string | Pre-auth device identifier |
| session_id | string | Current session identifier |
| page_url | string | Full URL where event fired |
| app_version | string | Deployed app version for debugging |
TypeScript Event Types
Define your event schema as TypeScript types. This gives you autocomplete, compile-time validation, and self-documenting code.
lib/analytics/events.ts
// Base context included with every event
interface BaseEventContext {
timestamp: string;
user_id?: string;
anonymous_id: string;
session_id: string;
page_url: string;
app_version: string;
}
// Define each event with its specific properties
interface EventSchemas {
// Lifecycle Events
user_signed_up: {
signup_method: 'email' | 'google' | 'github';
referral_source?: string;
};
user_logged_in: {
login_method: 'email' | 'google' | 'github' | 'sso';
};
user_logged_out: Record<string, never>; // No additional props
// Engagement Events
dashboard_created: {
dashboard_id: string;
template_used: string | null;
widget_count: number;
};
dashboard_shared: {
dashboard_id: string;
share_method: 'link' | 'email' | 'embed';
recipient_count: number;
};
report_exported: {
report_id: string;
format: 'pdf' | 'csv' | 'xlsx';
row_count: number;
};
// Conversion Events
subscription_started: {
plan_id: string;
plan_name: string;
billing_cycle: 'monthly' | 'annual';
trial: boolean;
};
payment_completed: {
amount_cents: number;
currency: string;
payment_method: 'card' | 'invoice';
};
// Feature Events
feature_enabled: {
feature_name: string;
feature_variant?: string;
};
}
// Union type of all event names
type EventName = keyof EventSchemas;
// Full event type including context
type TrackedEvent<T extends EventName> = BaseEventContext & {
event: T;
properties: EventSchemas[T];
};
export type { EventName, EventSchemas, TrackedEvent, BaseEventContext };Type-Safe Tracking Wrapper
Create a wrapper function that enforces types and adds context automatically. This becomes your single entry point for all tracking calls.
lib/analytics/tracker.ts
import type { EventName, EventSchemas } from './events';
import posthog from 'posthog-js'; // or mixpanel, segment, etc.
// Context provider (implement based on your app)
function getBaseContext() {
return {
timestamp: new Date().toISOString(),
user_id: getCurrentUserId(), // from auth context
anonymous_id: getAnonymousId(), // from cookie/localStorage
session_id: getSessionId(),
page_url: typeof window !== 'undefined' ? window.location.href : '',
app_version: process.env.NEXT_PUBLIC_APP_VERSION || 'unknown',
};
}
/**
* Type-safe event tracking function
*
* @example
* track('dashboard_created', {
* dashboard_id: 'dash_123',
* template_used: null,
* widget_count: 5
* });
*/
export function track<T extends EventName>(
event: T,
properties: EventSchemas[T]
): void {
const context = getBaseContext();
// Development: log to console
if (process.env.NODE_ENV === 'development') {
console.log('[Analytics]', event, { ...context, ...properties });
}
// Skip tracking in test environment
if (process.env.NODE_ENV === 'test') return;
// Send to analytics provider
posthog.capture(event, {
...properties,
// Spread context properties for easy filtering
$set: {
last_seen: context.timestamp,
app_version: context.app_version,
},
});
}
// Convenience function for page views
export function trackPageView(pageName: string, properties?: Record<string, unknown>) {
track('page_viewed' as EventName, {
page_name: pageName,
...properties,
} as EventSchemas[EventName]);
}Usage in Components
import { track } from '@/lib/analytics/tracker';
function CreateDashboardButton() {
const handleCreate = async () => {
const dashboard = await createDashboard({ template: 'blank' });
// TypeScript ensures correct properties
track('dashboard_created', {
dashboard_id: dashboard.id,
template_used: null,
widget_count: 0,
});
// TypeScript ERROR: Property 'widgets' does not exist
// track('dashboard_created', { dashboard_id: '123', widgets: 0 });
// TypeScript ERROR: Argument of type '"dashboard_made"' not assignable
// track('dashboard_made', { ... });
};
return <button onClick={handleCreate}>Create Dashboard</button>;
}Server vs Client Tracking
Different events belong in different places. Here's how to decide where to track.
Client-Side Tracking
- • UI interactions (clicks, hovers, scrolls)
- • Page views and navigation
- • Form field interactions
- • Feature discovery events
- • Error UI displays
Cons: Can be blocked by ad blockers
Server-Side Tracking
- • Conversion events (purchases, signups)
- • Backend state changes
- • API usage and errors
- • Webhook receipts
- • Background job completions
Cons: Less browser context available
Server-Side Tracking Example (Next.js API Route)
// app/api/dashboard/create/route.ts
import { trackServerEvent } from '@/lib/analytics/server';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { userId } = await getSession(request);
const body = await request.json();
const dashboard = await db.dashboard.create({
data: { ...body, userId },
});
// Server-side tracking - always fires
await trackServerEvent('dashboard_created', userId, {
dashboard_id: dashboard.id,
template_used: body.template || null,
widget_count: 0,
});
return NextResponse.json(dashboard);
}
// lib/analytics/server.ts
import { PostHog } from 'posthog-node';
const posthog = new PostHog(process.env.POSTHOG_API_KEY!);
export async function trackServerEvent<T extends EventName>(
event: T,
userId: string,
properties: EventSchemas[T]
) {
posthog.capture({
distinctId: userId,
event,
properties: {
...properties,
$set: { last_active: new Date().toISOString() },
},
});
// Flush immediately in serverless environments
await posthog.flush();
}Common Mistakes
Using `any` for Properties
track(event: string, properties: any) — defeats the entire purpose
Fix: Use generics with strict event schemas. Accept the initial typing effort.
Tracking Too Early in the Funnel
Tracking button clicks instead of successful completions.
Fix: Track outcomes, not intents. dashboard_created not create_button_clicked.
Inconsistent Property Types
Same property as string sometimes, number other times.
Fix: TypeScript enforces this. Define property types once; they're always consistent.
Not Handling Async Tracking
Navigating away before track call completes.
Fix: Use navigator.sendBeacon() for client-side, or flush explicitly on server.
Duplicating Tracking Logic
Tracking same event in multiple components, risking double-counting.
Fix: Track at the data mutation layer (API routes, state actions), not UI components.