Temporal values
Three field types, two value shapes. An instant crosses every boundary as a `Date`. A calendar day and a clock reading cross as exact strings and never become one.
| Field | Postgres | Server and typed client | Plain JSON | OpenAPI |
|---|---|---|---|---|
f.datetime() | timestamp(3) with time zone | Date | RFC 3339, Z on the way out | string, format: date-time |
f.date() | date | YYYY-MM-DD string | the same string | string, format: date |
f.time() | time(0) | HH:MM or HH:MM:SS(.s…) string | the same string | string, no temporal format |
A field stays nullable until you call .required(). OpenAPI widens the type of
a nullable field to ["string", "null"]. The format does not change.
Every rule below is about f.datetime() unless it names another type. The other
two need no wire handling, because a string survives a JSON hop unchanged.
import { collection } from "#questpie/factories";
export const events = collection("events").fields(({ f }) => ({
startsAt: f.datetime().required(),
dateOnly: f.date().required(),
opensAt: f.time(),
}));Writing
The datetime schema is a union of z.date() and an RFC 3339 string. That string
must carry a zone, and it becomes a Date before the write. The date schema is
z.string().date(), which takes that one string shape and nothing else.
| Value sent | f.datetime() | f.date() |
|---|---|---|
new Date(…) | Accepted | Rejected |
"2026-10-25T00:30:00.456Z" | Accepted | Rejected |
"2026-10-25T02:30:00.456+02:00" | Accepted, stored as …T00:30:00.456Z | Rejected |
"2026-10-25T02:30:00.456" | Rejected, no zone | Rejected |
"2026-10-25" | Rejected, a day has no epoch | Accepted |
1792888200456 | Rejected | Rejected |
null | Stored, unless .required() | Stored, unless .required() |
QUESTPIE never guesses a timezone. An offset-less string is a validation error. It is never read as local time on whichever machine ran the write.
`withTimezone: false` opts out of this contract
It builds a Postgres timestamp without time zone. Use it when you mean a
wall-clock value and own its interpretation. Every guarantee on this page is
about the default, withTimezone: true.
Filtering
eq, ne, not, gt, gte, lt, lte, in and notIn normalize their
operand before the query is built.
| Field type | Operand it takes | On anything else |
|---|---|---|
f.datetime() | Date, or RFC 3339 with Z or an offset | 400, Datetime filters require a Date or RFC 3339 value with Z or an explicit offset |
f.date() | Exact YYYY-MM-DD | 400, Date filters require an exact YYYY-MM-DD calendar date |
f.time() | Date or string, from the shared dateOps | Nothing is checked, the value reaches Postgres as written |
in and notIn normalize each element. not: null passes through as a null
check. isNull and isNotNull are outside the set. A field carrying .array()
skips normalization entirely.
Reading
Every read parses stored datetime values back into Date. That covers values
nested in an f.object() and items inside .array(). A null passes straight
through. A stored value that does not parse as an instant throws a TypeError
before it reaches your code. Date and time values pass through untouched.
Typed HTTP
createClient() opts into the typed envelope by default. Nested Date values
survive the hop, and a string that merely looks like a timestamp stays a string.
| Step | What happens |
|---|---|
| Request | Body encoded by stringifyTypedWire(), sent as application/superjson+json with X-SuperJSON: 1 |
| Server in | Envelope parsed when the request carries X-SuperJSON: 1, or application/superjson+json in Content-Type or Accept |
| Server out | Same envelope under the same content type, plain application/json otherwise |
| Response | Decoded by parseTypedWire() when the response content type contains superjson |
const startsAt = new Date("2026-10-25T00:30:00.456Z");
const created = await client.collections.events.create({
startsAt,
dateOnly: "2026-10-25",
});
created.startsAt instanceof Date; // true
created.startsAt.getTime() === startsAt.getTime(); // true
created.dateOnly; // "2026-10-25"createClient({ useSuperJSON: false }) is the plain-JSON mode. Requests go out
as JSON.stringify output and datetime values come back as RFC 3339 strings.
Any caller that never asks for the envelope gets that same plain contract. A
client built from your OpenAPI spec is one of them.
For a JSON boundary your own code owns, questpie/shared exports
stringifyTypedWire() and parseTypedWire(). Use the pair together.
Realtime and Channels
SSE frames, ordered Channel events, Channel replay and Pusher presence member
info share one codec. It leaves JSON.stringify output as it was. It then adds
one reserved top-level key, __questpieTypedWire, holding a wire version and
the exact paths that were Date.
| Consumer | What it sees |
|---|---|
JSON.parse, no codec | ISO strings at those paths, plus one extra property it can ignore |
| Current typed client | Only the marked paths restored to Date, and the key deleted before your callback |
Wire version 1 is the only one accepted. The codec fails closed. On the way
out it throws when the payload already carries the reserved key. On the way in
it throws on any of four faults.
- an unknown version
- a malformed or duplicated path
- a marked value that is not its own
toISOString() - more than 16,384 marked paths
A normal string is never revived by appearance.
The 10,000-byte Channel event limit is measured on the envelope with the metadata in it.
Query realtime over Pusher or Soketi carries invalidation notices on
questpie:invalidate rather than rows. The Date arrives with the typed HTTP
refetch that follows.
TanStack and SSR
@questpie/tanstack-query receives the typed client's values. dehydrate()
keeps Date instances in JavaScript state. The package pins one hydration path
with a test: dehydrate, Seroval round trip, hydrate. The Date survives and an
ISO-looking string stays a string.
@questpie/tanstack-db writes find() and live-snapshot docs straight into the
collection. No JSON clone stands between the row and your query.
A JSON-only hop you add yourself is yours: a cookie, a script tag, your own
cache. Use a serializer that keeps Date, such as the typed-wire pair.
Never install a global ISO-string reviver
It cannot tell an instant from a string that happens to look like one, so it corrupts legitimate text fields. QUESTPIE marks exact paths instead, which is why nothing here reads a value's shape to decide its type.
Related
f.datetime() is the field itself, its two
config keys, its methods and its admin control.
f.date() is the calendar day.
f.time() is the clock reading.
Client SDK covers createClient() and the rest of its
config.
Channels covers the ordered event surface this codec carries.
Arrays covers what .array() does to columns and
operators.
Upload field
f.upload() holds the id of a row in an upload collection. One asset in a varchar(36), or a gallery through a junction, and the admin renders a file picker rather than a record picker.
One row, many rows
hasMany reads the key off the other table, manyToMany goes through a junction collection, and multiple keeps a list of ids on the row itself.