Repository navigation
Typed content through type handles derived from the schema #373
Description
Problem
Typed content stops at create(). create<T>() returns content: T, but T isn't checked against the schema, and get, query, mutate, patchContent and subscribe all return Record<string, unknown>. Every read site casts, and a typo in a patch key is only caught at runtime.
Decision
Core derives an app's content type from its schema at compile time, and typed reads and writes go through a type handle.
Core can't contain TypeScript for an app's types, but it owns the schema format, which is small and fixed. So core ships a mapping from that format to TypeScript types, the way zod and TypeBox do. The app writes the schema once, as a literal, and the compiler derives the content type from it. There's no hand-written interface to keep in step with the schema.
const Book = typeHandle('com.example.reading/book@2', {
title: { kind: 'string', required: true },
status: { kind: 'string', enum: ['want', 'reading', 'finished', 'abandoned'], required: true },
pages: { kind: 'number' },
});
type BookContent = ContentOf<typeof Book.schema>;
// { title: string; status: 'want' | 'reading' | 'finished' | 'abandoned'; pages?: number }ContentOf<S>gives the content type.PatchOf<S>gives thecontentPatchtype: every field optional, andnullallowed only on optional fields, since a required field can be replaced but not removed.typeHandle(id, schema)is a plain value carrying theTypeId(Book.id), its family (Book.baseId) and the schema.Book.baseIdis there so a call that means the whole family, likemigrateAll(Book.baseId), is as easy to write as one that passesBook.idby mistake (Family arguments take a BaseId and refuse a TypeId #369). Because it's a value rather than adefineType()result,StackClientcode can use it. Itsconsttype parameter keeps the literal types, so callers don't needas const.- Typed reads take the handle, read with
presentAt: 'latest', and checktypeIdagainst the handle before narrowing. That runtime check is what makes a typed read more than a cast. A record of another Type throws, and so does one whose family has moved past the handle's version (the stale-writer casepresentAt: 'latest'already reports). - The derived types are a convenience layered on runtime validation, which stays the guarantee. A Type known only at runtime (loaded from a stack, or another app's) has no static shape and stays
Record<string, unknown>.
To decide in this issue
Enum values the handle doesn't know
Adding values to an enum is additive within a version (#372). So an older app's typed read can receive a value its derived union excludes, e.g. 'paused' when its type says 'want' | 'reading' | 'finished' | 'abandoned', and a switch the compiler calls exhaustive misses a case. Choose one:
- Check on read: validate
enumfields against the handle's schema and throw on an unknown value. The type stays exact; an older app fails loudly until it upgrades. - Wider type: derive
'want' | … | (string & {})so callers must handle unknown values. Nothing throws; editor completion still offers the known values, but exhaustiveness checks are lost.
Tombstones
Under ScopedStack, a soft-deleted record is served as a tombstone with content: {} (versioning.md § The tombstone is literal). A typed read that returns one would type that empty object as ContentOf<S>, claiming required fields it doesn't have. After #368, a plain get() never returns a tombstone, but get(…, { includeDeleted: true }) and query({ filter: { includeDeleted: true } }) still can. Choose one:
- Refuse
includeDeletedon typed reads. Typed reads only ever see live records; a caller who wants tombstones uses the untyped read. - Type the result as a union of a live record with
ContentOf<S>and a tombstone (deletedAt: Date,content: {}), so callers narrow ondeletedAtbefore touching content.
Prototype results
A prototype built against core's real FieldDef union (not committed) showed:
- A compile-time check that core's
TypeSchemafits the prototype's schema type passes, so every field kind has a mapping. - A test schema using every kind derives exactly the expected type: nested objects, arrays of arrays, and
openarrays and objects (unknown[]andRecord<string, unknown>). - These fail to compile: a typo'd patch key,
nullon a required field, a value outside the enum, an array with neitheritemsnoropen, and an unknownkind. - A round trip through a real
Stackworks, and reading a book through aShelfhandle throwsRecord … is com.example.reading/book@2, not com.example.reading/shelf@1. - Not checked yet: how readable compiler errors are for a subtly wrong nested schema, and editor hover output.
Actions
- Core: export
ContentOf<S>,PatchOf<S>,TypeHandle<S>andtypeHandle(). A handle carriesid,baseIdandschema. - Add typed read/write overloads to
StackClientthat take a handle (get,query,create,mutate/patchContent), implemented onStackandScopedStack. Typed reads usepresentAt: 'latest'and checktypeId. - Let
defineType()accept a handle'sidandschemadirectly, so one literal serves both. - Decide and implement the enum behavior above.
- Decide and implement the tombstone behavior above, with tests for typed
get()andquery()underincludeDeletedon bothStackandScopedStack. - Type tests in core pinning the mapping for every field kind (compile-time assertions, plus
@ts-expect-errorcases). - Spec,
data-model.md: where typed handles andpresentAt: 'latest'are documented, a note that a handle names exactly one version, and the enum and tombstone behaviors chosen above. - READMEs: show a handle in the quick starts.
- Changeset:
minorfor@haverstack/core.
Order
Blocked by #369 (the handle's baseId), #370 (derived types assume fields are never null), #372 (enum unions) and #368 (so a plain typed get() never returns a tombstone).
Activity