Type safety
Let the object declaration type every create, update, filter and read, and know where the types stop.
The declaration is the type. standards.from(builder) reads the builder's phantom types, so a select refuses an option it does not declare, a filter refuses an attribute that does not exist, and a record comes back with id, createdAt and updatedAt already there. No generated file, no codegen step.
import { date, number, object, relation, select } from "@stndrds/client";
import { getStandards } from "./client";
export const incident = object({ name: "incidents", label: "Incident" })
.attribute(relation({ name: "service", label: "Service" }).to("services").required())
.attribute(
select({ name: "impact", label: "Impact" })
.options([
{ value: "minor", label: "Minor" },
{ value: "major", label: "Major" },
{ value: "critical", label: "Critical" },
])
.required()
)
.attribute(date({ name: "startedAt", label: "Started at" }).includeTime())
.attribute(number({ name: "usersAffected", label: "Users affected" }))
.labelExpression("{{ impact }}");
const incidents = getStandards().from(incident);
await incidents.create({ service: "svc_1", impact: "major", startedAt: new Date() });
// @ts-expect-error "severe" is not one of the three declared options
await incidents.create({ service: "svc_1", impact: "severe" });
const open = await incidents.eq("impact", "critical").orderBy("startedAt", "desc").fetch();
open.records[0]?.startedAt; // string | Date statically, a Date at runtimeWhat each verb accepts
| Verb | Type | Built from |
|---|---|---|
create(input) | RecordInput<T> | ExtractRecordInputStrict<T> plus an open [key: string]: unknown index. |
update(id, patch) | RecordUpdate<T> | ExtractRecordUpdateStrict<T> plus the same open index. Every declared key optional. |
eq, gt, in, orderBy, … | keyof ExtractRecord<T> | Attribute names only; a typo is a compile error. |
fetch, single, get, and the return of every write | ExtractRecord<T> | Declared attributes, RecordMetadata, and an open index for runtime attributes. |
The strict base means your declared attributes keep their exact types: impact is "minor" | "major" | "critical", not string. The open index means an attribute a user added in the workspace is still writable, at unknown. The two together are why RecordInput is not the same as ExtractRecordInput: that helper's own index is the custom-attribute value union, which has no Date member and would refuse a Date for a runtime date attribute.
Dates
A date attribute is string | Date statically. On write, the client turns a Date into an ISO string before the request leaves. On read, it revives every date attribute, createdAt, updatedAt and deletedAt into Date objects, and a .endDate() range revives both bounds. Compare with instanceof Date when you want to be sure, or pass ISO strings to the filter verbs, which take string | number | boolean | null:
const cutoff = new Date(Date.now() - 7 * 86_400_000);
const stale = await incidents.lt("startedAt", cutoff.toISOString()).fetch();The helpers
Six helpers derive a type from a builder. Three include the open index for runtime attributes; the Strict three do not.
| Helper | Includes | Use it for |
|---|---|---|
ExtractRecord<T> | attributes, RecordMetadata, open index | What fetch and get return. |
ExtractRecordStrict<T> | attributes, RecordMetadata | A row you render and want exhaustively checked. |
ExtractRecordInput<T> | attributes, open index of custom values | A create payload built outside the client. |
ExtractRecordInputStrict<T> | attributes | The base of RecordInput<T>. |
ExtractRecordUpdate<T> | optional attributes, open index | A patch built outside the client. |
ExtractRecordUpdateStrict<T> | optional attributes | The base of RecordUpdate<T>. |
RecordMetadata is what the server adds to every record:
interface RecordMetadata {
id: string;
createdAt: Date;
updatedAt: Date;
visibility: "workspace" | "private";
ownedBy: string | null;
createdBy?: string;
lastUpdatedBy?: string;
metadata: Record<string, unknown>;
}Every field but one is omitted from the input and update types: id, createdAt, updatedAt, createdBy, lastUpdatedBy, visibility and ownedBy are server-owned. metadata is yours. Write it on create and update to keep an external id or any application data next to the record.
The zero-import form lives on the builder: typeof incident.$infer.record, .create and .update match ExtractRecord, ExtractRecordInput and ExtractRecordUpdate.
type Incident = typeof incident.$infer.record;
type NewIncident = typeof incident.$infer.create;The untyped escape hatch
standards.from("incidents") returns the same query against an object your code does not declare. Every attribute is unknown, every filter accepts any name, and create accepts any object. Dates are not revived, because the client has no definition to read. Reach for it in a migration script or a one-off, and declare the object as soon as the code depends on its shape.
Two functions from the status page
The status page cookbook records one ping per service and folds it into a daily row with upsertDailyStat. single() returns ExtractRecord<T> | null, so the two branches type-check without a cast, and existing.id is there because RecordMetadata is:
const stats = standards.from(dailyStat);
const existing = await stats.eq("service", serviceId).eq("day", day).single();
if (!existing) {
await stats.create({ service: serviceId, day, total: 1, failed: result.ok ? 0 : 1 });
return;
}
await stats.update(existing.id, { total: (existing.total ?? 0) + 1 });The full function, with the running latency average, is in the status page cookbook.
Paging is generic over the builder too: RecordsQuery<TBuilder> and FetchResult<TBuilder> are exported, so a paging helper such as fetchAll keeps the caller's record type.
Where the types stop
- A
relationreads asstringeven when.many()makes it an array on the wire. - Runtime attributes are
unknown. Narrow them yourself. - Past about 98 chained attributes, inference degrades to
anyfrom the first attribute on. See Objects. - The types describe the declaration, not the workspace. A sync that never ran leaves the types true and the API answering 404.
Next steps
- Records: every filter verb and what it sends.
- Errors: what a 404 on a never-synced object looks like.
- Attributes: the value type of each attribute builder.
- Status page cookbook: the two functions above in context.