Share types with the frontend
Use case
Your backend owns the canonical user model. You want to:
- Use it to build a
mongoose.Schemafor persistence. - Get a TypeScript type (
Infer<>) shared via a workspace package so the frontend’s request/response types stay in lockstep. - Emit a JSON Schema 2020-12 document the frontend can hand to a form generator (
@rjsf/core, JSON Forms, etc.) or that an MCP tool can use asinputSchema.
Sapphire was designed for exactly this. Define once, fan out to three consumers — one source for the IR keeps the three outputs aligned. (Per-adapter escape hatches can still drift if you layer them; treat them as the exception.)
End-to-end example
// packages/shared/src/schemas.ts -------------------------------------
import { Sapphire, registerAdapter, type Infer } from '@ascendance-hub/sapphire-core'
import { toMongooseSchema } from '@ascendance-hub/sapphire-mongoose'
import { toJsonSchema } from '@ascendance-hub/sapphire-json-schema'
registerAdapter('mongoose', toMongooseSchema)
registerAdapter('json-schema', toJsonSchema)
export const a = new Sapphire()
export const User = a
.object({
name: a.string().min(1).describe('Display name shown in the UI.'),
email: a.string().email().describe('Primary contact email.'),
age: a.number().int().min(0).optional(),
role: a.type().enum(['admin', 'editor', 'viewer'] as const),
})
.name('User')
// (1) Shared TypeScript type — import on both backend and frontend.
export type User = Infer<typeof User>
// (2) Mongoose schema — backend persistence.
import mongoose from 'mongoose'
export const UserMongoSchema = User.getSchema('mongoose') as mongoose.Schema
export const UserModel = mongoose.model('User', UserMongoSchema)
// (3) JSON Schema 2020-12 — frontend form generator OR MCP inputSchema.
export const UserJsonSchema = toJsonSchema(User.toSchema(), {
$id: 'https://example.com/schemas/user',
additionalProperties: false,
})
The frontend imports just the type and the JSON Schema:
// apps/web/src/forms/user-form.tsx -----------------------------------
import Form from '@rjsf/core'
import validator from '@rjsf/validator-ajv8'
import { UserJsonSchema, type User } from 'shared/schemas'
export function UserForm({ onSubmit }: { onSubmit: (u: User) => void }) {
return (
<Form
schema={UserJsonSchema as object}
validator={validator}
onSubmit={({ formData }) => onSubmit(formData as User)}
/>
)
}
Step by step
- Define
Useronce as a named object schema..name('User')registers it in the Sapphire instance’s registry — JSON Schema picks the name up for$defs, and Mongo uses it formongoose.model. - Three outputs from the same field:
Infer<typeof User>is a pure type expression — zero runtime cost.User.getSchema('mongoose')walks the IR throughtoMongooseSchemaand returns amongoose.Schema.toJsonSchema(User.toSchema(), opts)returns a plain JSON object.
- Ship the type and the JSON Schema to the frontend. The Mongoose schema stays server-side (it pulls in
mongoose). A workspacesharedpackage is the typical home — re-exported from there into both apps. - Refactor in one place. Add a field on
User, and the type updates everywhere, the Mongoose model gets a new path, and the form generator picks up the new question on next build.
Variations
partial() for PATCH endpoints
A PATCH endpoint typically accepts a subset of the model. Reuse the same source, derive the partial:
export const UserPatch = User.partial()
export type UserPatch = Infer<typeof UserPatch>
// JSON Schema for the PATCH endpoint:
export const UserPatchJsonSchema = toJsonSchema(UserPatch.toSchema())
See Concepts → Composition for pick / omit / extend / merge.
MCP tool inputSchema
Model Context Protocol expects JSON Schema for tool inputs. The same UserJsonSchema plugs in directly:
server.tool({
name: 'create_user',
description: 'Create a new user.',
inputSchema: UserJsonSchema as object,
handler: async (input) => {
// Validate again on the server for defence-in-depth:
const parsed = User.parse(input)
return UserModel.create(parsed)
},
})
Type-export across packages
The Infer<> result is a structural type. To export it cleanly from a workspace package without leaking Sapphire types into every consumer, re-export the type explicitly instead of the schema:
// shared/src/index.ts
export type { User } from './schemas' // frontend uses this
export { User as UserSchema } from './schemas' // backend uses this
The frontend bundle now contains zero Sapphire runtime — only the structural type survives.
[!WARNING] Don’t ship the Mongoose schema to the browser.
@ascendance-hub/sapphire-mongooseandmongooseitself are server-only. Export the JSON Schema and theInfer<>type from a shared package; keepUserMongoSchemaandUserModelin a backend-only module.
[!WARNING]
additionalProperties: falseis opt-in. JSON Schema’s spec default is permissive. If you want the form generator to reject unknown keys, pass{ additionalProperties: false }totoJsonSchema— Sapphire’sstripUnknown/unknown_keysemantics aren’t conveyed automatically.
See also
- Adapters → JSON Schema — full 2020-12 mapping table and emitter options.
- Adapters → Mongoose — Mongoose IR mapping and refs.
- Concepts → Composition —
partial,pick,omitfor read/write/patch splits. - Recipes → One schema, many adapters — sister recipe focused on multi-output emission.