@semantq/typecaster
v0.1.1
Published
Schema-aware type casting and structural validation for Semantq QL.
Readme
@semantq/typecaster
@semantq/typecaster is the schema-aware type system and frontend metadata primitive built into Semantq QL.
Its purpose is deliberately focused:
TypeCaster converts external values into schema-compatible typed values, converts database values into application/form representations, verifies prepared data before persistence, and derives reduced metadata for frontend editors.
Table of Contents
What TypeCaster Is
TypeCaster does not contain business rules.
It also does not ship the complete Prisma/database architecture to the frontend.
Instead, TypeCaster establishes a deliberate boundary:
SERVER
┌──────────────────────────────────────────────┐
│ │
│ Prisma / Database Schema │
│ │ │
│ ▼ │
│ SchemaReader │
│ │ │
│ ▼ │
│ Normalized Metadata │
│ │ │
│ ▼ │
│ TypeCaster │
│ / \ │
│ / \ │
│ ▼ ▼ │
│ Typed Service Editor Metadata │
│ Data │ │
│ │ │ │
│ ▼ ▼ │
│ Business Logic Frontend Editor │
│ │ │
│ ▼ │
│ assert() │
│ │ │
│ ▼ │
│ Database │
│ │
└──────────────────────────────────────────────┘The central architectural principle is:
The frontend receives a projection of the schema, not the schema itself.
TypeCaster Is Native to Semantq QL
TypeCaster is not a separate application dependency that developers need to install.
It is part of the Semantq QL server stack:
semantqQL/
└── packages/
└── @semantq/
└── typecaster/The application-level configured instance is exposed through:
semantqQL/lib/typecaster.jsServices normally consume it with:
import typeCaster from '../lib/typecaster.js';The underlying implementation lives at:
semantqQL/packages/@semantq/typecaster/The application adapter configures TypeCaster against the project's schema and generated registry.
The Complete TypeCaster Lifecycle
TypeCaster participates in both write and read flows.
Write flow
REQUEST / FORM DATA
│
▼
formToDbModel()
│
▼
TYPED APPLICATION DATA
│
▼
BUSINESS LOGIC
│
▼
assert()
│
▼
DATABASEThe invariant is:
CAST
↓
BUSINESS LOGIC
↓
ASSERT
↓
PERSISTRead flow
DATABASE
│
▼
dbToFormModel()
│
▼
APPLICATION / FORM REPRESENTATIONEditor metadata flow
PRISMA SCHEMA
│
▼
SchemaReader
│
▼
Normalized Metadata
│
▼
TypeCaster
│
▼
Editor Metadata
│
▼
FRONTEND EDITORThese are related flows, but they are not the same contract.
Persistence Schema vs Frontend Metadata
This is one of the most important design decisions in TypeCaster.
A Prisma schema can contain much more information than a frontend editor needs:
models
fields
relations
foreign keys
provider-specific attributes
database mappings
indexes
internal structural details
server-side relationships
persistence concernsThat information belongs to the backend.
The frontend generally needs a much smaller representation:
field name
value
editor type
required / optional
nullable
enum options
simple structural characteristicsTherefore TypeCaster deliberately separates:
BACKEND SCHEMA
≠
INTERNAL METADATA
≠
EDITOR METADATAThe complete schema remains a server-side concern.
The frontend receives a purpose-specific projection.
This gives Semantq QL a strong architectural boundary:
COMPLETE SCHEMA
│
▼
SchemaReader
│
▼
INTERNAL METADATA
│
▼
TypeCaster
│
▼
EDITOR METADATA
│
▼
FRONTENDThe frontend therefore does not need to know how the database is architected in order to edit a resource.
Why Metadata Projection Matters
Information minimisation
The frontend is not given internal persistence information that it does not need.
Reduced coupling
Frontend editors depend on a purpose-specific metadata contract instead of the Prisma schema.
Better separation of concerns
Database architecture remains server-side.
Editor behaviour remains frontend-oriented.
Smaller contracts
Only metadata necessary for the consuming application is projected.
Schema-aware editors
Editors can still be generated dynamically because the metadata remains derived from the authoritative schema.
The principle is:
Hide backend architecture without hiding the information required to operate the frontend.
Schema Changes and TypeCaster Generation
Whenever the Prisma schema changes, TypeCaster metadata must be regenerated.
This is an important part of the development lifecycle.
The workflow is:
EDIT prisma/schema.prisma
│
▼
Prisma generation / migration
│
▼
npm run typecaster --generate
│
▼
Updated TypeCaster registry
│
▼
ApplicationFor example:
npx prisma generate
npm run typecaster --generateIf the schema change affects the database, run the appropriate Prisma migration workflow as well.
Then regenerate TypeCaster:
npm run typecaster --generateThe complete development sequence is therefore:
# Change the schema
vim prisma/schema.prisma
# Update Prisma-generated artifacts
npx prisma generate
# Regenerate TypeCaster metadata
npm run typecaster --generateIf tests are available:
npm testWhy --generate Is Important
TypeCaster uses schema-derived metadata through its generated registry.
Conceptually:
prisma/schema.prisma
│
▼
TypeCaster generation
│
▼
typecaster.registry.js
│
▼
TypeCaster runtimeIf the Prisma schema changes but TypeCaster is not regenerated, the application can end up with:
CURRENT DATABASE / PRISMA SCHEMA
│
│
X
│
STALE TYPECASTER METADATAThis can produce stale:
field definitions
type information
enum information
relations
relation metadata
editor metadataTherefore:
A Prisma schema change should be followed by TypeCaster generation.
Running TypeCaster
After the Semantq QL server has been set up, TypeCaster is already available.
Run:
npm run typecasterThe CLI is located at:
packages/@semantq/typecaster/cli/typecaster.jsGenerate TypeCaster Metadata
The most important schema lifecycle command is:
npm run typecaster --generateRun this after Prisma schema changes and the relevant Prisma generation/migration step.
This updates the TypeCaster registry used by the application.
Conceptually:
Prisma schema
│
▼
SchemaReader
│
▼
MetadataBuilder
│
▼
Generated registry
│
▼
TypeCasterInspect the Schema
The CLI can inspect schema metadata:
npm run typecaster -- inspect ./prisma/schema.prismaThis is useful for examining:
models
fields
types
nullability
lists
enums
relations
relation metadataValidate the Schema
The schema can also be validated/loaded through the CLI:
npm run typecaster -- validate ./prisma/schema.prismaThe CLI is intentionally lightweight.
Resource generation and MCSR remain responsible for generating application resources and services.
Supported Types
TypeCaster currently supports:
| Type | Purpose |
| - | -- |
| String | String values |
| Int | Integer values |
| BigInt | Arbitrary-size integer values |
| Float | Floating-point values |
| Decimal | Precision-sensitive decimal values |
| Boolean | Boolean values |
| DateTime | Date/time values |
| Json | JSON-compatible values |
| Bytes | Binary values |
| Enum | Schema-defined enum values |
| Unsupported | Explicit failure for unsupported types |
It also understands structural characteristics such as:
required
nullable
list
non-list
enum membership
relations
relation scalar fieldsformToDbModel()
Use formToDbModel() when external data enters a service.
const data = typeCaster.formToDbModel(
req.body,
'Order'
);It:
- resolves the model schema
- resolves field types
- casts compatible input values
- rejects invalid primitive values
- handles basic nullability and structural constraints
- validates schema-defined enum values
- returns typed application/database data
For example, a request may contain:
{
"quantity": "25",
"active": "true",
"price": "149.95",
"deliveryDate": "2026-08-25T08:00:00Z"
}while the model expects:
quantity → Int
active → Boolean
price → Decimal
deliveryDate → DateTimeInstead of:
const data = {
quantity: Number(req.body.quantity),
active: req.body.active === 'true',
price: req.body.price,
deliveryDate: new Date(req.body.deliveryDate)
};the service uses:
const data = typeCaster.formToDbModel(
req.body,
'Order'
);The result is structurally typed:
data.quantity; // 25
data.active; // true
data.price; // Decimal representation
data.deliveryDate; // DateThis removes repetitive field-by-field casting from generated services.
TypeCaster Knows Types, Not Business Meaning
TypeCaster understands:
String
Int
BigInt
Float
Decimal
Boolean
DateTime
Json
Bytes
EnumThe service understands:
A customer must be over 18.
A completed order cannot be cancelled.
A manager must approve orders above a threshold.
A discharged resident requires a discharge date.For example:
const data = typeCaster.formToDbModel(
req.body,
'Order'
);
if (
data.status === 'CANCELLED' &&
data.shippedAt
) {
throw new Error(
'A shipped order cannot be cancelled.'
);
}
typeCaster.assert(
data,
'Order'
);The architectural boundary is:
TypeCaster
↓
structural/type rules
Service
↓
business/domain rulesTypeCaster should not become a business-rule engine.
assert()
assert() verifies that prepared data still conforms to the model contract.
typeCaster.assert(
data,
'Order'
);It does not:
cast
repair
transform
execute business rulesIts responsibility is verification.
For example:
const data = typeCaster.formToDbModel(
req.body,
'Order'
);
// Business logic
data.quantity = 'invalid';
typeCaster.assert(
data,
'Order'
);The assertion catches the structural violation before persistence.
Therefore:
formToDbModel()
= construct typed data
assert()
= verify prepared datadbToFormModel()
dbToFormModel() performs the reverse application-facing transformation.
const order =
await OrderModel.findById(id);
return typeCaster.dbToFormModel(
order,
'Order'
);Its role is broader than primitive conversion.
It establishes the boundary:
DATABASE / INTERNAL REPRESENTATION
│
▼
TypeCaster
│
▼
APPLICATION / FORM REPRESENTATIONThis is especially important when relational data is involved.
Relational Metadata
Relations are part of the TypeCaster metadata contract.
However:
Relations remain distinct from scalar field typing.
A model conceptually contains:
Model
├── scalar fields
├── enum fields
└── relationsFor example:
Resident
├── id
├── organizationId
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
├── externalRef
├── organization
├── behaviorEpisodes
├── behaviorDailyLogs
├── carePlans
├── representatives
└── assessmentsHere:
organizationIdis a scalar field.
While:
organizationis a relation.
They must not be treated as the same thing.
Relation Scalar Fields
Consider a Prisma relationship:
model Resident {
id String @id @default(uuid())
organizationId Int
organization Organization @relation(
fields: [organizationId],
references: [id]
)
firstName String
lastName String
}The relation descriptor is attached to:
organizationnot:
organizationIdTypeCaster therefore marks the scalar foreign-key field explicitly:
{
name: 'organizationId',
type: 'Int',
isRelation: false,
isRelationScalar: true
}while the relation remains:
{
name: 'organization',
type: 'Organization',
isRelation: true
}This allows downstream consumers to distinguish:
relation field
relation scalar / foreign key
ordinary scalar fieldThis is metadata normalisation, not editor-specific filtering.
IDs Are No Longer Part of Editor Metadata
A major architectural rule is that identifier fields are not exposed as editable frontend fields.
For example, a Resident persistence model may contain:
id
organizationId
firstName
lastName
preferredName
status
dateOfBirth
externalRefThe editor metadata can instead expose:
firstName
lastName
preferredName
status
dateOfBirth
externalRefIt does not expose:
id
organizationIdThe distinction is deliberate.
id is a persistence identity.
organizationId is a relation scalar/foreign key.
Neither is an ordinary user-editable field.
Therefore:
DATABASE MODEL
│
├── id → internal identity
├── organizationId → relation scalar
├── firstName → editable
├── lastName → editable
├── preferredName → editable
├── status → editable
└── dateOfBirth → editablebecomes:
EDITOR METADATA
│
├── firstName
├── lastName
├── preferredName
├── status
└── dateOfBirthThe frontend therefore does not need to know or manipulate persistence identifiers simply to render an editor.
Relational Nestedness
TypeCaster supports relational metadata, but an important distinction must be made:
The depth of relational data is determined by the data actually fetched, not by blindly exposing the entire schema graph.
Suppose the schema contains:
Resident
│
├── Organization
│
├── BehaviorEpisode
│ │
│ ├── BehaviorEvent
│ │
│ └── BehaviorIntervention
│
└── CarePlanThe schema describes possible relationships.
It does not mean every response should contain:
Resident
└── Organization
└── Residents
└── BehaviorEpisodes
└── ...That would expose unnecessary backend structure and could create enormous recursive graphs.
Instead, the fetched data determines the actual representation.
Fetch Depth Determines Representation Depth
Consider a shallow query:
const resident =
await prisma.resident.findUnique({
where: { id },
include: {
organization: true
}
});The result may conceptually be:
Resident
└── OrganizationTypeCaster works with the data actually returned.
If the query fetches:
const resident =
await prisma.resident.findUnique({
where: { id },
include: {
organization: true,
behaviorEpisodes: true
}
});the returned structure becomes:
Resident
├── Organization
└── BehaviorEpisodesIf the query goes deeper:
const resident =
await prisma.resident.findUnique({
where: { id },
include: {
behaviorEpisodes: {
include: {
events: true,
interventions: true
}
}
}
});the representation becomes:
Resident
└── BehaviorEpisode
├── BehaviorEvent
└── BehaviorInterventionThe key principle is:
SCHEMA
= possible relationship graph
FETCH
= selected relationship graph
DATA
= actual nested representationTherefore TypeCaster should not assume that every possible relation is present.
Why Fetch-Driven Nestedness Matters
This keeps the runtime representation proportional to the actual resource request.
For example:
FETCH ONLY RESIDENT
↓
ResidentFETCH RESIDENT + ORGANIZATION
↓
Resident
└── OrganizationFETCH RESIDENT + EPISODES
↓
Resident
└── BehaviorEpisodesFETCH RESIDENT + EPISODES + EVENTS
↓
Resident
└── BehaviorEpisodes
└── BehaviorEventsThe database schema describes what can exist.
The fetch describes what was requested.
The returned data determines what does exist in the application representation.
This is particularly important for Semantq QL because resource responses should not automatically become complete database graph serialisations.
Metadata Versus Fetched Data
These two concepts should remain distinct.
Metadata can say:
Resident
├── organization → Organization
├── behaviorEpisodes → BehaviorEpisode[]
├── carePlans → CarePlan[]
└── assessments → ResidentAssessment[]But the actual response might contain only:
{
id: 'resident-001',
firstName: 'Jane',
lastName: 'Doe'
}or:
{
id: 'resident-001',
firstName: 'Jane',
lastName: 'Doe',
organization: {
id: 10,
name: 'Example Organisation'
}
}or:
{
id: 'resident-001',
firstName: 'Jane',
lastName: 'Doe',
behaviorEpisodes: [
{
id: 'episode-001',
events: [
{
id: 'event-001'
}
]
}
]
}TypeCaster must operate on the actual returned structure.
It should not invent nested data simply because the schema permits the relationship.
Editor Metadata for a Model
A reduced editor projection might look like:
{
Resident: {
fields: {
firstName: {
editor: 'text',
required: true,
nullable: false
},
lastName: {
editor: 'text',
required: true,
nullable: false
},
preferredName: {
editor: 'text',
required: false,
nullable: true
},
status: {
editor: 'text',
required: true,
nullable: false
},
dateOfBirth: {
editor: 'datetime-local',
required: false,
nullable: true
},
externalRef: {
editor: 'text',
required: false,
nullable: true
}
}
}
}Notice what is absent:
id
organizationId
organization
behaviorEpisodes
behaviorDailyLogs
carePlans
representatives
assessmentsThis is intentional.
The editor metadata represents the editable surface, not the complete persistence architecture.
Editor Metadata Is Frontend-Focused
Editor metadata exists to answer questions such as:
What fields can the editor display?
Which editor should be used?
Is the field required?
Can the field be null?
What enum options are available?
What value should be displayed?It is not intended to answer:
How is this database table indexed?
What is the complete relational graph?
What database foreign keys exist?
What provider-specific database mapping is being used?
What internal persistence structures exist?Those remain backend concerns.
Field Projection Examples
String
Internal:
{
name: 'firstName',
type: 'String',
nullable: false,
isList: false
}Editor projection:
{
name: 'firstName',
value: 'Jane',
editor: {
type: 'text',
required: true
}
}Int
{
name: 'quantity',
value: 25,
editor: {
type: 'number',
required: true
}
}Boolean
{
name: 'active',
value: true,
editor: {
type: 'checkbox',
required: true
}
}DateTime
{
name: 'deliveryDate',
value: '2026-08-25T08:00:00.000Z',
editor: {
type: 'datetime',
required: false
}
}Enum
{
name: 'status',
value: 'ACTIVE',
editor: {
type: 'select',
options: [
'PENDING',
'ACTIVE',
'COMPLETED',
'CANCELLED'
],
required: true
}
}The frontend receives what it needs to render and operate the field.
Enum Metadata
A schema-defined enum may internally be represented as:
{
name: 'status',
type: 'OrderStatus',
nullable: false,
isList: false,
values: [
'PENDING',
'ACTIVE',
'COMPLETED',
'CANCELLED'
]
}The editor projection can then become:
{
name: 'status',
value: 'ACTIVE',
editor: {
type: 'select',
options: [
'PENDING',
'ACTIVE',
'COMPLETED',
'CANCELLED'
],
required: true
}
}The editor does not need the complete Prisma enum declaration.
It needs the usable options.
TypeCaster and Metadata Architecture
TypeCaster has several distinct metadata layers.
PRISMA
│
▼
SchemaReader
│
▼
MetadataBuilder
│
▼
NORMALIZED METADATA
│
┌────────┴────────┐
│ │
▼ ▼
Runtime TypeCaster Editor Builder
│ │
▼ ▼
Typed application Editor metadata
data │
▼
FRONTENDThe normalized metadata may contain information such as:
field name
field type
required
nullable
isList
isRelation
relation
attributes
isRelationScalar
enum informationThe editor builder then reduces this into the frontend-facing contract.
This is important:
Editor metadata is derived from normalized metadata, but it is not the normalized metadata itself.
MetadataBuilder
MetadataBuilder is responsible for normalising schema-derived metadata.
It handles:
models
fields
enums
relations
relation scalar identification
nullability
required state
list stateFor example, a relation scalar can be marked:
{
name: 'organizationId',
type: 'Int',
isRelationScalar: true
}while a relation remains:
{
name: 'organization',
type: 'Organization',
isRelation: true
}This gives downstream consumers enough information to make their own purpose-specific decisions.
SchemaReader
SchemaReader is responsible for reading/parsing the schema representation.
The conceptual pipeline is:
Prisma schema
│
▼
SchemaReader
│
▼
Raw schema metadata
│
▼
MetadataBuilder
│
▼
Normalized metadataTypeCaster operates on the normalized representation rather than embedding Prisma parsing logic throughout the runtime.
ModelRegistry
The model registry resolves models by name.
Conceptually:
typeCaster.getModel('Resident');returns the registered model metadata.
A model can contain fields such as:
Resident
├── id
├── organizationId
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
├── externalRef
├── organization
├── behaviorEpisodes
├── behaviorDailyLogs
├── carePlans
├── representatives
├── assessments
└── CarePlanProgressThe registry is an internal runtime concern.
It should not be confused with the frontend editor metadata.
Type Handlers
Each supported type follows a common conceptual contract:
formToDb(value, metadata)
dbToForm(value, metadata)
assert(value, metadata)The registry resolves the appropriate handler from metadata.
For example:
Int
↓
Int handler
Boolean
↓
Boolean handler
DateTime
↓
DateTime handler
Enum
↓
Enum handlerThis keeps the core generic.
The TypeCaster core does not need domain-specific knowledge about:
Resident
Order
Customer
Product
Invoice
OrganizationIt needs structural metadata.
Structural Validation Versus Business Validation
These are deliberately separate.
TypeCaster asks:
Is this an Int?
Is this a Boolean?
Is this a valid DateTime?
Is this a valid enum value?
Is this required field null?
Is this value structurally a list?The service asks:
Can this resident be discharged?
Can this order be cancelled?
Can this user modify the organization?
Does this business process permit this transition?
Does this value satisfy the organization's policy?This separation keeps TypeCaster reusable across domains.
Service Pattern
A Semantq QL service can follow:
async create(req) {
const data = typeCaster.formToDbModel(
req.body,
'Order'
);
// =========================================================
// BUSINESS LOGIC
// =========================================================
// Basic mutation:
// data.someIntField += 1;
// data.someStringField =
// data.someStringField.trim();
// Extended business logic:
//
// if (
// data.status === 'DISCHARGED' &&
// !data.dischargeDate
// ) {
// throw new Error(
// 'A discharge date is required.'
// );
// }
typeCaster.assert(
data,
'Order'
);
const result =
await OrderModel.create(data);
return typeCaster.dbToFormModel(
result,
'Order'
);
}The service developer focuses on the business logic.
TypeCaster handles the structural type boundary.
Update Pattern
Updates follow the same lifecycle:
async update(id, input) {
const data =
typeCaster.formToDbModel(
input,
'Order'
);
// =========================================================
// BUSINESS LOGIC
// =========================================================
// Domain-specific transformations and rules go here.
typeCaster.assert(
data,
'Order'
);
const result =
await OrderModel.update(
id,
data
);
return typeCaster.dbToFormModel(
result,
'Order'
);
}The invariant remains:
external input
↓
formToDbModel()
↓
typed data
↓
business logic
↓
assert()
↓
persistence
↓
dbToFormModel()TypeCaster and MCSR
TypeCaster is particularly useful within Semantq's MCSR/resource-generation architecture.
Generated services can automatically establish:
const typedData =
typeCaster.formToDbModel(
data,
'Order'
);
// BUSINESS LOGIC
typeCaster.assert(
typedData,
'Order'
);
const result =
await OrderModel.create(
typedData
);
return typeCaster.dbToFormModel(
result,
'Order'
);This means MCSR does not need to generate repetitive:
Number(...)
Boolean(...)
new Date(...)operations for every field.
The division of responsibility becomes:
TypeCaster
↓
"What type is this data?"
MCSR
↓
"Where does the structural lifecycle go?"
Developer
↓
"What does this data mean?"Generated Editor Architecture
The same schema-aware foundation can support editor generation.
Conceptually:
Prisma Schema
│
▼
Normalized Metadata
│
▼
EditorMetadataBuilder
│
▼
Reduced Editor Contract
│
▼
Semantq FrontendThe editor contract can describe:
editable fields
editor type
required state
nullable state
enum options
valueswhile deliberately excluding:
database IDs
foreign keys
relations
database implementation detailsunless a specific frontend feature explicitly requires a separate relation-aware contract.
The Important Distinction: Schema Versus Editable Surface
A database model might contain:
Resident
├── id
├── organizationId
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
├── externalRef
├── organization
├── behaviorEpisodes
├── behaviorDailyLogs
├── carePlans
├── representatives
└── assessmentsThe editable surface might be:
Resident Editor
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
└── externalRefThe two representations serve different purposes.
DATABASE MODEL
= persistence architecture
EDITOR MODEL
= user-editable representationTypeCaster provides the machinery for deriving the second from the first without exposing the first wholesale.
Nested Resources and Editor Boundaries
Relations may exist in the backend without becoming editable fields.
For example:
Resident
├── organization
├── behaviorEpisodes
├── carePlans
└── assessmentsdoes not mean the Resident editor should contain:
organizationId
behaviorEpisodeIds
carePlanIds
assessmentIdsas ordinary text fields.
Instead, relation-aware interfaces can be handled as separate resource operations.
This keeps the ordinary editor contract clean:
Scalar editor fields
│
▼
ordinary form editingwhile relations remain:
resource relationships
│
▼
fetch / nested resources / dedicated relation UIFetch Data Controls Relational Depth
The schema defines relationships.
The query determines which relationships are fetched.
The returned data determines the nested representation.
Therefore:
SCHEMA GRAPH
↓
possible relationships
FETCH GRAPH
↓
requested relationships
RESULT GRAPH
↓
actual nested dataThis is the correct model for relational nestedness in Semantq QL.
A query that fetches:
Residentdoes not automatically become:
Resident
└── Organization
└── Residents
└── Episodes
└── Events
└── ...Instead, nestedness is explicit and controlled by the resource/data fetch.
This protects:
performance
payload size
frontend simplicity
security boundaries
query predictabilityDatabase Representation Versus Frontend Representation
The complete architecture can therefore be visualised as:
DATABASE
│
▼
Prisma Model
│
▼
SchemaReader
│
▼
Normalized Metadata
│
┌───────────┴───────────┐
│ │
▼ ▼
TypeCaster Editor Metadata Builder
│ │
│ ▼
│ Frontend Contract
│ │
│ ▼
│ UI / Editor
│
▼
Typed Application Data
│
▼
Business Logic
│
▼
assert()
│
▼
DatabaseThis is the core TypeCaster architecture.
Complete Example
Suppose Prisma defines:
model Resident {
id String @id @default(uuid())
organizationId Int
firstName String
lastName String
preferredName String?
status ResidentStatus
dateOfBirth DateTime?
externalRef String?
organization Organization @relation(
fields: [organizationId],
references: [id]
)
behaviorEpisodes BehaviorEpisode[]
carePlans CarePlan[]
}The internal metadata can distinguish:
id
persistence identity
organizationId
scalar relation field
firstName
ordinary scalar
lastName
ordinary scalar
preferredName
nullable scalar
status
enum scalar
dateOfBirth
nullable DateTime
externalRef
nullable scalar
organization
relation
behaviorEpisodes
relation list
carePlans
relation listThe editor projection can become:
{
Resident: {
fields: {
firstName: {
editor: 'text',
required: true,
nullable: false
},
lastName: {
editor: 'text',
required: true,
nullable: false
},
preferredName: {
editor: 'text',
required: false,
nullable: true
},
status: {
editor: 'select',
required: true,
nullable: false
},
dateOfBirth: {
editor: 'datetime-local',
required: false,
nullable: true
},
externalRef: {
editor: 'text',
required: false,
nullable: true
}
}
}
}The frontend does not need:
id
organizationIdas editable fields.
It also does not need the complete relation graph simply to render the Resident editor.
Runtime Example
A database query may fetch:
const resident =
await prisma.resident.findUnique({
where: {
id: residentId
},
include: {
organization: true,
behaviorEpisodes: {
include: {
events: true
}
}
}
});The resulting data might conceptually be:
Resident
├── firstName
├── lastName
├── status
├── Organization
│
└── BehaviorEpisodes
└── EventsThe nestedness exists because it was fetched.
The schema merely made those relationships possible.
This distinction is fundamental:
TypeCaster understands the relational metadata, but it does not invent relational data.
Recommended Development Workflow
For normal schema-driven development:
1. Edit Prisma schema
↓
2. Run Prisma generation
↓
3. Run database migration if required
↓
4. Run TypeCaster generation
↓
5. Inspect generated metadata if necessary
↓
6. Run tests
↓
7. Run the applicationCommands:
npx prisma generate
npm run typecaster --generate
npm run typecaster -- inspect ./prisma/schema.prisma
npm testThe exact Prisma migration command depends on the environment and whether the schema change affects the database.
The critical TypeCaster rule is:
PRISMA SCHEMA CHANGE
↓
npm run typecaster --generatePrisma Schema Annotations
TypeCaster supports lightweight annotations in the Prisma schema for controlling frontend editor metadata.
The general concept is:
- Prisma continues to define the actual database/model type.
- TypeCaster reads the annotation at build/runtime metadata generation.
- The annotation enriches the generated editor metadata.
- This allows the schema to describe frontend editing behaviour without hard-coding that behaviour in application components.
- The annotations are specifically intended for TypeCaster's editor metadata layer.
Default Select Editor for Enums with @selected
statuses TypeCasterDemoStatus[] ///@selected:PENDINGExplanation:
TypeCasterDemoStatus[]is a Prisma enum list.- Because the field is an enum, TypeCaster generates a
selecteditor. - The enum values become the editor's
options. @selected:PENDINGspecifies the selected/default editor value.- The selected value must be one of the enum values.
Expected conceptual metadata:
{
"editor": "select",
"required": true,
"nullable": false,
"options": [
"ACTIVE",
"INACTIVE",
"PENDING"
],
"selected": "PENDING"
}The annotation does not change the Prisma enum or its database representation. It only supplies editor metadata.
Predefined Key-Value Editor
contactData Json? /// @editor predefined-key-values email:email mobile:number url:urlExplanation:
- The underlying Prisma field remains
Json?. @editor predefined-key-valuestells TypeCaster to expose the JSON value through a key-value editor with a predefined set of keys.- The keys are:
emailmobileurl
- The value after each colon specifies the editor used for that key.
Syntax:
@editor predefined-key-values key:editor key:editor ...For the example:
email:email
mobile:number
url:urlConceptual generated metadata:
{
"editor": "key-value",
"required": false,
"nullable": true,
"structure": {
"type": "predefined-key",
"fields": {
"email": {
"editor": "email"
},
"mobile": {
"editor": "number"
},
"url": {
"editor": "url"
}
}
}
}"Predefined-key" means the available keys are determined by the schema annotation rather than being arbitrary user-created keys.
Custom Key-Value Editor
attributes Json? /// @editor custom-key-value key:text value:textExplanation:
- The underlying Prisma field remains
Json?. @editor custom-key-valuetells TypeCaster to expose the JSON value through a custom key-value editor.- Unlike
predefined-key-values, the user can define arbitrary keys. key:textdefines the editor used for the key.value:textdefines the editor used for the value.
Syntax:
@editor custom-key-value key:editor value:editorConceptual generated metadata:
{
"editor": "key-value",
"required": false,
"nullable": true,
"structure": {
"type": "custom-key-value",
"key": {
"editor": "text"
},
"value": {
"editor": "text"
}
}
}Combined Example
model TypeCasterDemo {
id String @id @default(cuid())
statuses TypeCasterDemoStatus[] ///@selected:PENDING
contactData Json? /// @editor predefined-key-values email:email mobile:number url:url
attributes Json? /// @editor custom-key-value key:text value:text
}
enum TypeCasterDemoStatus {
ACTIVE
INACTIVE
PENDING
}TypeCaster can therefore derive:
statuses→selecteditor with enum options andPENDINGselected.contactData→ predefined key-value editor.attributes→ custom key-value editor.
Annotation Reference
| Annotation | Purpose |
| --------------------------------------- | --------------------------------------------------- |
| ///@selected:PENDING | Sets the selected/default value for an enum editor |
| /// @editor predefined-key-values ... | Creates a key-value editor with schema-defined keys |
| /// @editor custom-key-value ... | Creates a key-value editor with user-defined keys |
For @selected, the annotation applies to enum editor metadata and the selected value should correspond to an available enum value.
For the editor annotations, the annotation is attached to the Prisma field and interpreted by TypeCaster.
Editor Metadata vs Prisma Type
Annotations do not replace or mutate the Prisma field type.
Examples:
contactData Json?remains a JSON field in Prisma, even when annotated:
contactData Json? /// @editor predefined-key-values email:email mobile:number url:urlLikewise:
attributes Json?remains JSON when annotated with custom-key-value.
The annotation describes how TypeCaster should represent the field to the frontend editor layer.
Package Structure
The TypeCaster package is structured as:
semantqQL/
├── packages/
│ └── @semantq/
│ └── typecaster/
│ ├── package.json
│ ├── README.md
│ ├── index.js
│ │
│ ├── cli/
│ │ └── typecaster.js
│ │
│ ├── core/
│ │ ├── TypeCaster.js
│ │ ├── TypeRegistry.js
│ │ ├── SchemaReader.js
│ │ ├── ModelRegistry.js
│ │ ├── MetadataBuilder.js
│ │ └── EditorMetadataBuilder.js
│ │
│ ├── types/
│ │ ├── String.js
│ │ ├── Int.js
│ │ ├── BigInt.js
│ │ ├── Float.js
│ │ ├── Decimal.js
│ │ ├── Boolean.js
│ │ ├── DateTime.js
│ │ ├── Json.js
│ │ ├── Bytes.js
│ │ ├── Enum.js
│ │ └── Unsupported.js
│ │
│ └── providers/
│ └── PostgreSQL.js
│
└── lib/
└── typecaster.jsThe exact implementation may evolve, but the architectural boundaries should remain.
Design Principles
1. Schema-driven
The schema is the authoritative source of structural type information.
2. Boundary-oriented
External values are cast when entering the application.
Prepared values are asserted before persistence.
3. Business-logic independent
TypeCaster does not contain domain rules.
4. Metadata-aware
TypeCaster operates from normalized schema metadata.
5. Frontend-focused metadata
Editor metadata is a reduced projection designed for frontend consumers.
6. Backend architecture remains backend architecture
The complete Prisma/database schema is not automatically exposed to the frontend.
7. Relations remain distinct from scalar typing
A relation such as:
organizationis not the same metadata concept as:
organizationId8. Foreign keys are not ordinary editor fields
Relation scalar fields can be identified internally without exposing them as editable UI fields.
9. IDs are not editor fields
Persistence identity belongs to the backend/resource layer, not the ordinary frontend editor contract.
10. Nestedness is fetch-driven
The schema describes possible relationships.
The fetch determines which relationships are present.
The actual data determines the runtime nested structure.
11. Explicit lifecycle
The normal service lifecycle is:
CAST
↓
BUSINESS LOGIC
↓
ASSERT
↓
PERSIST
↓
FORM / API REPRESENTATION12. Native to Semantq QL
TypeCaster is part of the Semantq QL server architecture and does not require separate installation.
The Central TypeCaster Contract
TypeCaster can ultimately be understood through four boundaries.
Input boundary
EXTERNAL DATA
↓
formToDbModel()
↓
TYPED APPLICATION DATABusiness boundary
TYPED APPLICATION DATA
↓
BUSINESS LOGIC
↓
PREPARED DATAPersistence boundary
PREPARED DATA
↓
assert()
↓
DATABASEFrontend boundary
DATABASE / FETCHED DATA
↓
dbToFormModel()
↓
APPLICATION REPRESENTATION
SCHEMA METADATA
↓
EDITOR METADATA PROJECTION
↓
FRONTENDThe complete architectural principle is:
TypeCaster knows what data is. The service decides what the data means.
And for the frontend:
The frontend receives a purpose-specific projection of the schema, not the backend schema itself.
And for relational data:
The schema defines possible relationships; the fetch determines relational depth; the returned data determines the actual nested representation.
And for schema lifecycle:
When the Prisma schema changes, regenerate TypeCaster metadata with
npm run typecaster --generate.
Summary of Changes
- Added new Table of Contents entry for "Prisma Schema Annotations" with all sub-sections
- Added complete new section documenting:
- Default select editor for enums with
@selected - Predefined key-value editor
- Custom key-value editor
- Combined example
- Annotation reference table
- Editor metadata vs Prisma type distinction
- Default select editor for enums with
- Preserved all existing content exactly as provided
- Used British English throughout the new section
- Maintained consistent formatting with existing README
- Kept all canonical examples exactly as specified
