Typed State Machines for Conversation Context
How we eliminated runtime type errors in session context handling by introducing typed accessor interfaces, and why untyped JSON in a conversational system is a maintenance liability.
Managing conversational state is one of the harder engineering problems in WhatsApp-native applications. A user might send a partial invoice description, then ask an unrelated question, then return to the conversation days later. The system must maintain context without leaking state between users or corrupting partial data.
The Problem with Untyped Context
Our initial implementation stored session context as a free-form JSON object in PostgreSQL. Handlers extracted values with type assertions:
const pending = (session.context as any).pendingMessage;
This worked, but it created an implicit contract between the code that wrote context and the code that read it, a contract enforced only at runtime. As the codebase grew, mismatches accumulated. A handler might write { pendingMessage: string } while another read { pending_message: string }. TypeScript caught none of this.
The ctx<T>() Accessor Pattern
Our solution was a typed accessor function paired with per-state interface declarations:
export function ctx<T>(context: Record<string, unknown>): T {
return context as unknown as T;
}
export interface ReactivationContext {
reactivation: true;
pendingMessage?: string;
}
export interface AddressConfirmContext {
originalAddress: string;
googleAddress: string;
forOnboarding?: { pendingMessage?: string };
forCustomer?: { customerId: string; invoiceId: string };
}
Any handler that reads session context now declares its expected type explicitly. TypeScript enforces that the correct fields are accessed. Mismatches between write-side and read-side interfaces become compile-time errors rather than production surprises.
Redis TTL and the Database Fallback
The active task stack lives in Redis with a one-hour TTL. To prevent data loss across inactivity periods, the system saves a partial task snapshot to the PostgreSQL session table before the TTL expires. On the user's next message, whether hours or days later, the system detects the partial context, offers to resume, and reconstructs the task stack from the saved snapshot.
For the reactivation case (thirty or more days of inactivity), a separate flow applies: the user is directed to log in via the web application, and their original pending message is held in the typed session context until they return. The same ctx<ReactivationContext> accessor is used on both the write path and the read path, ensuring the round-trip is always type-safe.
The Lesson
Conversational systems accumulate state. Every session state variant, every context shape, every possible transition is a surface area for bugs. Investing in typed interfaces for that state, even when the underlying storage is untyped JSON, pays dividends proportional to the number of state transitions in the system. In Kaepsi, that number is in the dozens. The investment was modest. The reduction in runtime errors was not.