Growth OS
Get Started
Intermediate18 min read

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.

Recommended: Object-Action Pattern
PatternExamplesUse Case
{object}_{action}dashboard_created, report_exportedCore product events
{object}_{action}_{result}payment_submitted_successEvents with outcomes
page_viewedpage_viewed + page propertyPage views (single event type)
feature_{action}feature_enabled, feature_disabledFeature 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.

Standard Event Context
PropertyTypeDescription
timestampISO8601When the event occurred (auto-filled)
user_idstringAuthenticated user identifier
anonymous_idstringPre-auth device identifier
session_idstringCurrent session identifier
page_urlstringFull URL where event fired
app_versionstringDeployed 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
Pros: Rich context (viewport, referrer, UTM params)
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
Pros: 100% reliable, can't be blocked
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

1.

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.

2.

Tracking Too Early in the Funnel

Tracking button clicks instead of successful completions.

Fix: Track outcomes, not intents. dashboard_created not create_button_clicked.

3.

Inconsistent Property Types

Same property as string sometimes, number other times.

Fix: TypeScript enforces this. Define property types once; they're always consistent.

4.

Not Handling Async Tracking

Navigating away before track call completes.

Fix: Use navigator.sendBeacon() for client-side, or flush explicitly on server.

5.

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.

Implementation Checklist

Type System

Implementation

Validation