Skip to content
trakoo
Esc
navigateopen⌘Jpreview
On this page

Type Safety

Infer event inputs, validated outputs, and user traits from one runtime registry.

trakoo binds each analytics instance to an event registry. Passing that registry value to a factory gives track() exact wire names and the correct properties for each name, without event generics or manual collection types.

Why it matters

Analytics mistakes are quiet: a typo can create a second event or an unusable property and remain unnoticed until a report is wrong. A registry-bound instance catches them while you write code.

await analytics.track('user_signed_up', {
  email: 'ada@example.com',
  plan: 'pro'
});

// TypeScript rejects:
// - 'user_signedup' because it is not a registry wire name
// - 'emai' because it is not a property
// - 'premium' because it is outside the plan union

Define the registry

import { defineEvents, noProperties, typed } from 'trakoo';

export const appEvents = defineEvents({
  userSignedUp: {
    name: 'user_signed_up',
    category: 'user',
    properties: typed<{
      email: string;
      plan: 'free' | 'pro' | 'enterprise';
      referralSource?: string;
    }>()
  },
  sessionStarted: {
    name: 'session_started',
    category: 'user',
    properties: noProperties()
  }
});

defineEvents() preserves the name and category literals while checking every definition. typed<T>() declares a validator-free object shape. noProperties() declares an event whose call has no properties argument.

No const assertion, collection interface, or exported event generic is required.

Infer at the factory

import { createClientAnalytics } from 'trakoo/client';
import { appEvents } from './events';

export const analytics = createClientAnalytics({
  events: appEvents,
  providers: [/* ... */]
});

The server factory infers from the same value:

import { createServerAnalytics } from 'trakoo/server';
import { appEvents } from './events';

export const serverAnalytics = createServerAnalytics({
  events: appEvents,
  providers: [/* ... */]
});

Both factories return fresh, independent instances. The application owns the returned instance and can export it from its own module for call sites to import.

Per-event call signatures

The registry produces a different tuple for each wire name:

await analytics.track('user_signed_up', {
  email: 'ada@example.com',
  plan: 'pro'
});

await analytics.track('session_started');

The first call requires properties. The second rejects a properties argument. On the server, property-bearing events accept an optional third options argument, while propertyless events accept options directly as the second argument.

Type user traits

Traits use the same validator-free marker:

import { typed } from 'trakoo';
import { createClientAnalytics } from 'trakoo/client';
import { appEvents } from './events';

interface UserTraits {
  email: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
  company?: string;
  role?: 'admin' | 'member' | 'viewer';
}

const analytics = createClientAnalytics({
  events: appEvents,
  userTraits: typed<UserTraits>(),
  providers: [/* ... */]
});

analytics.identify('user_123', {
  email: 'ada@example.com',
  name: 'Ada Lovelace',
  plan: 'pro',
  role: 'admin'
});

userTraits changes trait typing only. The event registry is still inferred from events.

Complex property types

Nested objects, array-valued fields, optional fields, and unions work naturally inside the top-level object shape:

const commerceEvents = defineEvents({
  purchaseCompleted: {
    name: 'purchase_completed',
    category: 'conversion',
    properties: typed<{
      orderId: string;
      currency: 'USD' | 'EUR' | 'GBP';
      items: Array<{ productId: string; quantity: number }>;
      shippingAddress: {
        city: string;
        country: string;
      };
    }>()
  },
  paymentProcessed: {
    name: 'payment_processed',
    category: 'conversion',
    properties: typed<{
      method: 'card' | 'paypal' | 'crypto';
      amount: number;
      cardDetails?: { last4: string; brand: string };
    }>()
  }
});

The top-level properties type itself must be a non-array, non-callable object shape.

Input and provider-output types

With typed<T>(), input and output are both T. A direct Standard Schema validator can have different input and output types because it parses or transforms data:

import { defineEvents } from 'trakoo';
import { z } from 'zod';

const commerceEvents = defineEvents({
  orderCompleted: {
    name: 'order_completed',
    category: 'conversion',
    properties: z.object({
      orderId: z.string(),
      amount: z.coerce.number().positive()
    })
  }
});

Standard Schema is an interface supported by validator libraries, not a required trakoo runtime. The schema input drives track() arguments; its validated output is what providers receive.

Use the shared type maps when another function needs one side explicitly:

import type { EventInputMap, EventOutputMap } from 'trakoo';

type OrderInput = EventInputMap<typeof commerceEvents>['order_completed'];
type OrderOutput = EventOutputMap<typeof commerceEvents>['order_completed'];

function trackOrder(input: OrderInput) {
  return commerceAnalytics.track('order_completed', input);
}

OrderInput follows the schema input. OrderOutput is the parsed object delivered to providers.

Event categories

The EventCategory shared type includes common categories while allowing domain-specific strings:

import { defineEvents, typed, type EventCategory } from 'trakoo';

const category: EventCategory = 'user';

const aiEvents = defineEvents({
  responseGenerated: {
    name: 'ai_response_generated',
    category: 'ai',
    properties: typed<{ model: string }>()
  }
});

Compile time and runtime

Compile-time checks protect typed call sites. At runtime:

  • defineEvents() provides the registry lookup.
  • typed<T>() checks only that a property-bearing event receives an object.
  • noProperties() rejects a supplied properties argument.
  • A direct Standard Schema validator validates and can transform individual fields.

Validation failures drop by default or throw when configured. They do not retain the input payload. See Events for the full policy.

Best practices

  • Prefer typed<T>() until runtime validation or transformation has a concrete purpose.
  • Use specific unions instead of broad string values when the domain is closed.
  • Mark optional properties with ?.
  • Use noProperties() instead of an empty object type.
  • Pass the same registry value through events everywhere; do not add factory event generics.
  • Use userTraits: typed<UserTraits>() for custom traits.

Next steps

Was this page helpful?