npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.js

Services 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()
        │
        ▼
DATABASE

The invariant is:

CAST
  ↓
BUSINESS LOGIC
  ↓
ASSERT
  ↓
PERSIST

Read flow

DATABASE
    │
    ▼
dbToFormModel()
    │
    ▼
APPLICATION / FORM REPRESENTATION

Editor metadata flow

PRISMA SCHEMA
     │
     ▼
SchemaReader
     │
     ▼
Normalized Metadata
     │
     ▼
TypeCaster
     │
     ▼
Editor Metadata
     │
     ▼
FRONTEND EDITOR

These 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 concerns

That 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 characteristics

Therefore TypeCaster deliberately separates:

BACKEND SCHEMA
     ≠
INTERNAL METADATA
     ≠
EDITOR METADATA

The 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
                    │
                    ▼
                FRONTEND

The 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
        │
        ▼
Application

For example:

npx prisma generate
npm run typecaster --generate

If the schema change affects the database, run the appropriate Prisma migration workflow as well.

Then regenerate TypeCaster:

npm run typecaster --generate

The 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 --generate

If tests are available:

npm test

Why --generate Is Important

TypeCaster uses schema-derived metadata through its generated registry.

Conceptually:

prisma/schema.prisma
        │
        ▼
TypeCaster generation
        │
        ▼
typecaster.registry.js
        │
        ▼
TypeCaster runtime

If the Prisma schema changes but TypeCaster is not regenerated, the application can end up with:

CURRENT DATABASE / PRISMA SCHEMA
              │
              │
              X
              │
STALE TYPECASTER METADATA

This can produce stale:

field definitions
type information
enum information
relations
relation metadata
editor metadata

Therefore:

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 typecaster

The CLI is located at:

packages/@semantq/typecaster/cli/typecaster.js

Generate TypeCaster Metadata

The most important schema lifecycle command is:

npm run typecaster --generate

Run 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
     │
     ▼
TypeCaster

Inspect the Schema

The CLI can inspect schema metadata:

npm run typecaster -- inspect ./prisma/schema.prisma

This is useful for examining:

models
fields
types
nullability
lists
enums
relations
relation metadata

Validate the Schema

The schema can also be validated/loaded through the CLI:

npm run typecaster -- validate ./prisma/schema.prisma

The 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 fields

formToDbModel()

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  → DateTime

Instead 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;  // Date

This 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
Enum

The 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 rules

TypeCaster 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 rules

Its 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 data

dbToFormModel()

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 REPRESENTATION

This 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
└── relations

For example:

Resident
├── id
├── organizationId
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
├── externalRef
├── organization
├── behaviorEpisodes
├── behaviorDailyLogs
├── carePlans
├── representatives
└── assessments

Here:

organizationId

is a scalar field.

While:

organization

is 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:

organization

not:

organizationId

TypeCaster 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 field

This 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
externalRef

The editor metadata can instead expose:

firstName
lastName
preferredName
status
dateOfBirth
externalRef

It does not expose:

id
organizationId

The 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         → editable

becomes:

EDITOR METADATA
      │
      ├── firstName
      ├── lastName
      ├── preferredName
      ├── status
      └── dateOfBirth

The 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
   │
   └── CarePlan

The 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
└── Organization

TypeCaster 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
└── BehaviorEpisodes

If 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
     └── BehaviorIntervention

The key principle is:

SCHEMA
  = possible relationship graph

FETCH
  = selected relationship graph

DATA
  = actual nested representation

Therefore 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
        ↓
Resident
FETCH RESIDENT + ORGANIZATION
        ↓
Resident
└── Organization
FETCH RESIDENT + EPISODES
        ↓
Resident
└── BehaviorEpisodes
FETCH RESIDENT + EPISODES + EVENTS
        ↓
Resident
└── BehaviorEpisodes
     └── BehaviorEvents

The 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
assessments

This 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                   │
                              ▼
                          FRONTEND

The normalized metadata may contain information such as:

field name
field type
required
nullable
isList
isRelation
relation
attributes
isRelationScalar
enum information

The 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 state

For 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 metadata

TypeCaster 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
└── CarePlanProgress

The 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 handler

This keeps the core generic.

The TypeCaster core does not need domain-specific knowledge about:

Resident
Order
Customer
Product
Invoice
Organization

It 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 Frontend

The editor contract can describe:

editable fields
editor type
required state
nullable state
enum options
values

while deliberately excluding:

database IDs
foreign keys
relations
database implementation details

unless 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
└── assessments

The editable surface might be:

Resident Editor
├── firstName
├── lastName
├── preferredName
├── status
├── dateOfBirth
└── externalRef

The two representations serve different purposes.

DATABASE MODEL
    = persistence architecture

EDITOR MODEL
    = user-editable representation

TypeCaster 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
└── assessments

does not mean the Resident editor should contain:

organizationId
behaviorEpisodeIds
carePlanIds
assessmentIds

as 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 editing

while relations remain:

resource relationships
        │
        ▼
fetch / nested resources / dedicated relation UI

Fetch 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 data

This is the correct model for relational nestedness in Semantq QL.

A query that fetches:

Resident

does 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 predictability

Database 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()
                │
                ▼
             Database

This 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 list

The 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
organizationId

as 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
     └── Events

The 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 application

Commands:

npx prisma generate

npm run typecaster --generate

npm run typecaster -- inspect ./prisma/schema.prisma

npm test

The 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 --generate

Prisma 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:PENDING

Explanation:

  • TypeCasterDemoStatus[] is a Prisma enum list.
  • Because the field is an enum, TypeCaster generates a select editor.
  • The enum values become the editor's options.
  • @selected:PENDING specifies 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:url

Explanation:

  • The underlying Prisma field remains Json?.
  • @editor predefined-key-values tells TypeCaster to expose the JSON value through a key-value editor with a predefined set of keys.
  • The keys are:
    • email
    • mobile
    • url
  • 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:url

Conceptual 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:text

Explanation:

  • The underlying Prisma field remains Json?.
  • @editor custom-key-value tells TypeCaster to expose the JSON value through a custom key-value editor.
  • Unlike predefined-key-values, the user can define arbitrary keys.
  • key:text defines the editor used for the key.
  • value:text defines the editor used for the value.

Syntax:

@editor custom-key-value key:editor value:editor

Conceptual 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:

  • statusesselect editor with enum options and PENDING selected.
  • 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:url

Likewise:

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.js

The 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:

organization

is not the same metadata concept as:

organizationId

8. 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 REPRESENTATION

12. 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 DATA

Business boundary

TYPED APPLICATION DATA
      ↓
BUSINESS LOGIC
      ↓
PREPARED DATA

Persistence boundary

PREPARED DATA
      ↓
assert()
      ↓
DATABASE

Frontend boundary

DATABASE / FETCHED DATA
      ↓
dbToFormModel()
      ↓
APPLICATION REPRESENTATION

SCHEMA METADATA
      ↓
EDITOR METADATA PROJECTION
      ↓
FRONTEND

The 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

  1. Added new Table of Contents entry for "Prisma Schema Annotations" with all sub-sections
  2. 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
  3. Preserved all existing content exactly as provided
  4. Used British English throughout the new section
  5. Maintained consistent formatting with existing README
  6. Kept all canonical examples exactly as specified