-
Notifications
You must be signed in to change notification settings - Fork 201
Feature: Compile-time field guards for collection CRUD operations #1450
Description
Problem
When certain fields on a collection should only be updated through specific optimistic actions (e.g., a status field that triggers server-side effects via createOptimisticAction), there's currently no way to prevent direct collection.update() calls from modifying those fields at the type level.
For example, given a collection like:
interface Order {
id: string
name: string
status: "pending" | "approved" | "rejected"
}
const orders = createCollection<Order>({
id: "orders",
// ...
})
// This is the intended way to update status — it calls an API with side effects
const updateStatus = createOptimisticAction({
onMutate: ({ id, status }) => {
orders.update(id, (draft) => { draft.status = status })
},
mutationFn: async ({ id, status }) => {
await api.updateOrderStatus(id, status) // triggers side effects
},
})
// But nothing prevents someone from doing this directly:
orders.update("order-1", (draft) => {
draft.status = "approved" // ← bypasses the API, no side effects
})Today the only option is a runtime guard (e.g., checking field names in a mutation handler or proxy wrapper), but there's no compile-time safety. This is especially problematic in larger teams where conventions are easy to miss.
Proposed Solution
Introduce a mechanism to mark certain fields as "guarded" or "readonly" at the collection level, so that collection.update(), collection.insert(), and collection.delete() type-check against a restricted subset of the type.
A few possible API shapes (not mutually exclusive):
Option A: guardedFields config option
const orders = createCollection<Order>({
id: "orders",
guardedFields: ["status"] as const,
// ...
})
// Type error: Property 'status' does not exist on type WritableDeep<Omit<Order, "status">>
orders.update("order-1", (draft) => {
draft.status = "approved" // ← compile error
})
// Optimistic actions could bypass the guard via a flag or separate internal type
const updateStatus = createOptimisticAction({
onMutate: ({ id, status }) => {
orders.update(id, { bypassGuard: true }, (draft) => {
draft.status = status // ← allowed
})
},
// ...
})Option B: Generic type parameter for update input
Similar to how TInsertInput already allows the insert type to differ from TOutput, introduce a TUpdateInput parameter:
type OrderUpdatable = Omit<Order, "status">
const orders = createCollection<Order, string, {}, typeof schema, Order, OrderUpdatable>({
id: "orders",
// ...
})
// draft is WritableDeep<OrderUpdatable> — no 'status' property
orders.update("order-1", (draft) => {
draft.status = "approved" // ← compile error
})Option C: Readonly<> marker on the type itself
Leverage TypeScript's readonly modifier — fields marked readonly on the type would be excluded from WritableDeep in the update callback:
interface Order {
id: string
name: string
readonly status: "pending" | "approved" | "rejected" // ← not writable via update()
}This is the least invasive but reuses readonly semantics in a potentially confusing way since readonly in TS doesn't normally mean "can never be set."
Current Workaround
Runtime validation in a mutation handler or custom wrapper around the collection that strips guarded fields. This works but doesn't surface errors at development time.
Additional Context
The TInsertInput generic already establishes the pattern of having different input types for different operations. Extending this pattern to updates feels natural. The proxy-based WritableDeep<TInput> draft in update() callbacks is the main type surface that would need to be narrowed.