# Time field (/docs/schema/fields/time)

---
title: Time field
description: "`f.time()` is a clock reading with no date and no time zone. Its two config keys look like a pair and move entirely separate things."
kind: reference
package: questpie
---

## Signature

| Call                             | Column                   | Accepted input             |
| -------------------------------- | ------------------------ | -------------------------- |
| `f.time()`                       | `time(0)`                | `HH:MM` or `HH:MM:SS(.s…)` |
| `f.time({ precision: n })`       | `time(n)`, `n` is 0 to 6 | unchanged                  |
| `f.time({ withSeconds: false })` | `time(0)`                | `HH:MM` only               |

The column is `time without time zone`. QUESTPIE never hands Drizzle a
`withTimezone` option, so the zoned variant is out of reach.

A time value is a `string` in TypeScript, in JSON and through the typed client.
Nothing converts it to a `Date` at any boundary. None of the instant handling
around `f.datetime()` reaches it.

```ts title="src/questpie/server/collections/businesses.ts"
import { collection } from "#questpie/factories";

export const businesses = collection("businesses").fields(({ f }) => ({
	name: f.text().required(),
	opensAt: f.time().required(), // time(0), NOT NULL
	closesAt: f.time().required(),
	lastCall: f.time({ withSeconds: false }), // nullable, HH:MM only
}));
```

## What the schema accepts

The derived schema is `z.string().regex(…)`. The config picks which regex.

| `withSeconds`       | Accepts                           | Rejects                     |
| ------------------- | --------------------------------- | --------------------------- |
| `true`, the default | `09:30`, `09:30:00`, `09:30:00.5` | `9:30`, `24:00`, `09:60`    |
| `false`             | `09:30`                           | `09:30:00`, `9:30`, `24:00` |

Both patterns want two digits for the hour, so `09:30` passes and `9:30` fails.
Hours run `00` to `23`, minutes and seconds `00` to `59`. Rejection reads
`Invalid time format.` and then the shape it wanted. The default pattern asks
for `Expected HH:MM or HH:MM:SS`. The strict one asks for `Expected HH:MM`.

<Callout type="warn" title="`withSeconds: true` is the loose setting">
	The name reads like a demand for seconds. It makes them optional. `false` is
	the strict one, `HH:MM` and nothing longer. The admin's time input emits
	minute precision, so a seconds-required default once made time fields
	unsavable.
</Callout>

<Callout type="info" title="`precision` and `withSeconds` never meet">
	`precision` reaches the Postgres column and stops there. `withSeconds` reaches
	the regex and stops there. So `{ precision: 3 }` stores milliseconds while the
	schema still takes `09:30`. And `{ withSeconds: false }` leaves the column at
	`time(0)`.
</Callout>

## Methods

The type adds none of its own. Its `fieldType()` declaration carries no methods
bag. You get the shared chain and nothing more: `.required()`, `.default()`,
`.label()`, `.localized()`, `.array()`, `.zod()` and the rest on
[Fields](/docs/schema/fields).

`.default("09:00")` type-checks against `string`. It lands on the Postgres
column as a column default too. Nothing runs that literal past the regex first.

<Callout type="warn" title="No `.autoNow()` here">
	`.autoNow()` and `.autoNowUpdate()` belong to the date and datetime factories.
	A bare clock reading has no framework notion of now. Write the literal you
	want with `.default()`, or compute one in a `beforeChange` field hook.
</Callout>

## Filtering

A time field carries `dateOps`. `f.datetime()` uses the same set. `f.date()`
does not. It carries `dateStringOps`, the same eleven operators with a `string`
operand. So a time field's `where` entry types to exactly these keys.

| Operator              | Operand                  | Matches                                          |
| --------------------- | ------------------------ | ------------------------------------------------ |
| `eq`, `ne`            | `Date \| string`         | Equal, not equal                                 |
| `not`                 | `Date \| string \| null` | Not equal, or `IS NOT NULL` when you pass `null` |
| `gt`, `gte`           | `Date \| string`         | Later, at or later                               |
| `lt`, `lte`           | `Date \| string`         | Earlier, at or earlier                           |
| `in`, `notIn`         | `(Date \| string)[]`     | In the list, not in the list                     |
| `isNull`, `isNotNull` | `boolean`                | Null check, inverted when you pass `false`       |

A bare value types alongside that object. `{ opensAt: "09:00" }` compiles to
`eq`, and `{ opensAt: null }` to `IS NULL`.

```ts
const { docs } = await app.collections.businesses.find({
	where: { opensAt: { lte: "12:00" }, closesAt: { gt: "18:00" } },
});
```

<Callout type="warn" title="Pass the string, not a `Date`">
	The operand types as `Date | string` only because the set is shared. The where
	builder normalizes `datetime` and `date` operands and names no other type. A
	time operand reaches Postgres exactly as you wrote it. Give it the string you
	store.
</Callout>

## In the admin

The form control is `TimeField`. It wraps a native `<input type="time">` with a
clock icon and a clear button that writes `null`. The list cell is `TimeCell`.
It prints the stored string in tabular figures, and a `-` for null. Its column
is 180 pixels wide by default. The filter builder gives the field its own
`<input type="time">` and the date operator list.

Neither config key crosses to that control.

- **`precision` does not.** The control carries its own `precision`, `"minute"`
  or `"second"`. Nothing derives it from the field, so the step stays at 60
  seconds. `f.time({ precision: 3 })` widens the column only. What moves the
  step is `.admin({ precision: "second" })`. That key is undeclared, and the
  untyped signature below lets it through.
- **The pattern does not.** `f.time()` records no `maxLength`, `pattern`, `min`
  or `max`, so nothing carries the configured regex across.

<Callout type="warn" title="The browser checks a different pattern">
	The admin prefers the server's JSON Schema. Without one it falls back to a Zod
	schema. That schema's `time` case hardcodes its own regex. The regex takes a
	one-digit hour, rejects fractional seconds and ignores `withSeconds`. Trust
	the server message over the form's.
</Callout>

`.admin(config)` sets the rest of the control. It appears on the field once
`@questpie/admin` is enabled. Its generated signature takes `unknown`, so
nothing type-checks the keys you pass.

| Key           | Type     | Effect                        |
| ------------- | -------- | ----------------------------- |
| `placeholder` | `string` | Placeholder text on the input |

That one key is the whole declared list. `showInList`, `listWidth`, `sortable`,
`filterable`, `hidden`, `readOnly`, `colspan` and the rest come from the base
admin config every field type shares.

## Lists

`.array()` replaces the column with `jsonb`. It replaces the operator set too.
The four ordering keys go, and so do `ne`, `not`, `in` and `notIn`. `eq`
survives but now takes the whole list. `.minItems(n)` and `.maxItems(n)` bound
the list.

```ts
slots: f.time().array().maxItems(24),
```

<Callout type="warn" title="Array items get a text box, not a picker">
	The admin allowlists `text`, `number`, `email`, `textarea` and `select` as
	array item types. `time` is missing from it, so each row falls back to a plain
	text input. The server still checks every item against the regex.
</Callout>

[Arrays](/docs/schema/fields/array) has the operator table that replaces this
one.

## Types

A time field contributes `string` to the row and the insert. Its `where` operand
widens to `Date | string`, inherited from the shared date operator set.
`.required()` makes it non-null and mandatory on insert. Without it the column
is nullable and the value selects as `string | null`. `.default(…)` makes the
input optional.

```ts
type Business = typeof businesses.$infer.select;
//   ^? { id: string; opensAt: string; lastCall: string | null; ... }
```

```ts
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Business = CollectionDoc<"businesses">;
type BusinessFilter = CollectionWhere<"businesses">;
```

## Related

[Temporal values](/docs/schema/fields/temporal-values) is the wire contract for
all three temporal types. Its tables carry the time row too. That row is a
`time(0)` column, the same string in and out, and no temporal format in the
OpenAPI schema.

[`f.datetime()`](/docs/schema/fields/datetime) is the instant. It stores a
`Date` in a `timestamptz` column.

[`f.date()`](/docs/schema/fields/date) is the calendar day, an exact
`YYYY-MM-DD` string. It owns `.autoNow()`, and so does `f.datetime()`.

[Arrays](/docs/schema/fields/array) covers what `.array()` does to reads, writes
and filters.

[Validation](/docs/schema/validation) covers the derived schema and `.zod()`.

[Reading and writing](/docs/schema/collections/crud) covers the rest of `where`.
