Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Data conventions

Two conventions run through all the data in stream-chat: the server's timestamps are nanosecond numbers, and your own fields live under custom, typed by interfaces you extend.

Timestamps

Every date the server sends is a TimestampNS: Unix time in nanoseconds, as a number. For example, a message sent on 9 October 2025 at 08:53:20 UTC has a created_at of 1760000000000000000.

Compare and sort timestamps as they are, since they're plain numbers: a.created_at < b.created_at. Convert them where they meet a Date, a date library or Date.now(), with the helpers stream-chat exports:

Helper Returns
nsToDate(ts) a Date
convertTimestampToDate(ts) a Date, or undefined when ts is missing or not a finite number
nsToMs(ts) Unix time in milliseconds, for arithmetic with Date.now()
nsToRfc3339(ts) an RFC 3339 string that keeps the sub-millisecond part
dateToNs(date), msToNs(ms) a TimestampNS from a Date or from milliseconds
nowNs() the current time as a TimestampNS
asTimestampNS(n) the same number typed as a TimestampNS, for a value already in nanoseconds, such as one read back from storage
import { nsToDate, nsToMs } from "stream-chat";

const sentAt = nsToDate(message.created_at);
const minutesAgo = Math.round(
  (Date.now() - nsToMs(message.created_at)) / 60_000,
);

console.log(`Sent ${sentAt.toLocaleString()}, ${minutesAgo} minutes ago`);

What goes wrong without them:

  • new Date(message.created_at) reads nanoseconds as milliseconds and gives an invalid date. The types catch it: the result isn't typed as a Date, so using it as one doesn't compile.
  • Passing the number to a date library, or comparing it with Date.now(), compiles but gives wrong results. Convert it first.
  • An optional timestamp is missing when it isn't set. In the messages a message list holds, pinned_at and deleted_at are then undefined. Other optional timestamps, and every timestamp in a raw response or an event, can also be null. Test for a missing one with == null, which matches both; === null misses undefined.

The fields of a request that take a date, such as a reminder's remind_at, are typed Date. To send a server timestamp back, pass nsToDate(ts), or nsToRfc3339(ts) where the precision matters. Durations, delays and intervals in the client's settings are in milliseconds.

Nanosecond values are larger than JavaScript represents exactly, so they're rounded to about 256 nanoseconds. Ordering always holds, but two values may not be exactly equal after a round trip through JSON.

Custom data types

Your own fields on a message, a channel, a user and the other objects live in that object's custom field: message.custom, channel.data.custom, user.custom. To type them, extend the matching interface of stream-chat in a declaration file that your TypeScript project includes:

stream-custom-data.d.ts
import "stream-chat";

declare module "stream-chat" {
  interface CustomMessageData {
    priority?: "high" | "normal";
  }

  interface CustomChannelData {
    image?: string;
    name?: string;
  }

  interface CustomUserData {
    department?: string;
  }

  interface CustomEventTypes {
    order_shipped: true;
  }

  interface CustomEventData {
    order_id?: string;
  }
}

Every read and write of those fields is then typed:

const priority = message.custom.priority; // "high" | "normal" | undefined
const channelName = channel.data?.custom?.name;

// A custom event: the type is declared in CustomEventTypes, its fields go under `custom`.
await channel.sendEvent({
  event: { type: "order_shipped", custom: { order_id: "42" } },
});

channel.on("order_shipped", (event) => {
  console.log(`Order ${event.custom.order_id} shipped`);
});

In the filters that take custom fields, a custom field takes a custom. prefix, as in { "custom.priority": "high" }. These are the filters for channels, members, users, threads, polls, search and the moderation review queue. Other filters, such as those for reactions and reminders, don't take custom fields. A channel's name is the exception: filter it as name.

The interfaces, and what each one types:

Interface Types
CustomMessageData message.custom
CustomChannelData channel.data.custom, and the custom you pass when creating a channel
CustomUserData user.custom
CustomMemberData a channel member's custom
CustomAttachmentData an attachment's custom. It already declares mime_type and file_size, and your fields add to them
CustomReactionData a reaction's custom
CustomPollData a poll's custom
CustomPollOptionData a poll option's custom
CustomThreadData a thread's custom
CustomEventData an event's custom
CustomEventTypes your own event types: each key becomes a valid event type. Only the keys count, so any value type works
CustomMessageComposerData the data a composer keeps for the UI and doesn't send, see Message composer

CustomCommandData is exported, but nothing reads it, so extending it has no effect.

A few things to keep in mind:

  • A field you declare without ? becomes required in every object of that type, including test data. Declare fields optional unless the server always sends them.
  • Keep all the declarations in one file, so every part of the app sees the same types.
  • Keep custom data small: it travels with every message, channel or user that carries it.
  • After upgrading the SDK, check that the declarations still compile.

Used in