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 aDate, 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_atanddeleted_atare thenundefined. Other optional timestamps, and every timestamp in a raw response or an event, can also benull. Test for a missing one with== null, which matches both;=== nullmissesundefined.
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:
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
- SDK integration, for the custom fields of AI messages
- Attachments and uploads, for custom attachments