Better Activity

Entities & Type Safety

Declare entities once, get type-safe save/list/paginate everywhere.

The single most important thing you do with better-activity is declare your entities. An entity is anything in your system that can be acted on — user, project, invoice, team — and each entity has a fixed list of actions that can be logged against it.

That registry drives the entire type-safety story.

Defining entities

Entities are declared inline on betterActivity({ entities }):

const activity = betterActivity({
  database,
  entities: {
    user: {
      actions: ["created", "updated", "deleted", "logged_in", "logged_out"],
      metadata: {} as { ip?: string; userAgent?: string },
    },
    project: {
      actions: ["created", "archived", "restored", "member_added"],
    },
  },
});

actions is preserved as a literal tuple (no as const needed) because of how betterActivity() is generic-typed. That literal tuple is what makes save({ entity: "user", action: "..." }) autocomplete and reject typos.

Per-entity metadata types

The metadata field is a type-only brand. The runtime value is ignored — you cast an empty object to the shape you want metadata to have for this entity:

entities: {
  user: {
    actions: ["logged_in"],
    metadata: {} as { ip: string; userAgent: string },
  },
}

Now save({ entity: "user", ... }) requires metadata of that shape, and the returned record's metadata is correctly narrowed:

const event = await activity.save({
  entity: "user",
  entityId: "u1",
  action: "logged_in",
  metadata: { ip: "1.2.3.4", userAgent: "Firefox" },
});

event.metadata.ip; // string | undefined — typed

Without a metadata brand, the field falls back to Record<string, unknown> for that entity.

defineEntity helper

If you want to build entities outside the betterActivity() call — because you split them across files or share them between packages — use defineEntity:

import { defineEntity } from "better-activity";

const user = defineEntity({
  actions: ["logged_in", "logged_out"],
  metadata: {} as { ip: string },
});

const project = defineEntity({
  actions: ["created", "archived"],
});

export const activity = betterActivity({
  database,
  entities: { user, project },
});

defineEntity preserves the literal tuple of actions so callers don't need as const.

What gets type-checked

Every method on activity reads from the entities map.

// ✅ valid
await activity.save({ entity: "user", entityId: "u1", action: "logged_in" });

// ✗ TS error — "archived" is not declared for "user"
await activity.save({ entity: "user", entityId: "u1", action: "archived" });

// ✗ TS error — "ghost" is not in entities
await activity.save({ entity: "ghost", entityId: "x", action: "created" });

// ✗ TS error — "metadata.banana" is not on user metadata
await activity.save({
  entity: "user",
  entityId: "u1",
  action: "logged_in",
  metadata: { banana: 1 },
});

The same constraint applies to list, paginate, between, and count — the entity and action filters are typed.

Runtime strictness

In addition to compile-time safety, better-activity runs a runtime check by default. If entities is provided and a save() call uses an unknown entity or action, it throws.

// throws UnknownActionError at runtime if strict=true (default)
await (activity.save as any)({
  entity: "user",
  entityId: "u1",
  action: "fly_to_mars",
});

You can opt out — useful if you're persisting raw events from an external system you can't change:

const activity = betterActivity({
  database,
  entities: { user: { actions: ["logged_in"] } },
  strict: false,
});

With strict: false, unknown entities and actions are persisted as-is.

Inferring types out of the instance

The activity instance exposes a $Infer type-only namespace for re-using the inferred record / input shapes elsewhere in your code:

type Event = typeof activity.$Infer.Record;
type Input = typeof activity.$Infer.SaveInput;

function render(event: Event) {
  // event.entity / event.action are typed as the unions of all entities/actions
}

When not to declare entities

You can call betterActivity({ database }) without an entities map at all. In that mode, entity and action accept any string and there is no runtime validation. This is fine for prototyping but you give up the main reason to use the library — once you have a few event types, declare them.

On this page