Getting Started
Sapphire lets you define a schema once and emit TypeScript types plus ORM-specific outputs (MongoDB via Mongoose or the native driver, Drizzle tables, JSON Schema 2020-12) from the same source.
This guide walks you through installation, your first schema, parsing/validation, and plugging in an adapter.
Install
Sapphire is published as a small set of workspace packages. Install the core, then any adapters you need.
Core only (just types, parsing, and the adapter registry):
npm install @ascendance-hub/sapphire-core
Core plus the Mongoose adapter (mongoose is a peer dependency):
npm install @ascendance-hub/sapphire-core @ascendance-hub/sapphire-mongoose mongoose
Core plus the Drizzle adapter (drizzle-orm is a peer dependency, supported range ^0.44 || ^0.45):
npm install @ascendance-hub/sapphire-core @ascendance-hub/sapphire-drizzle drizzle-orm
Core plus the JSON Schema 2020-12 adapter (no extra peer deps โ bring your own validator like AJV if you need runtime checks):
npm install @ascendance-hub/sapphire-core @ascendance-hub/sapphire-json-schema
Core plus the native MongoDB driver adapter (no extra peer deps โ emits a $jsonSchema collection validator for db.createCollection):
npm install @ascendance-hub/sapphire-core @ascendance-hub/sapphire-bson
Your first schema
Create a Sapphire instance and define an object schema with the field DSL:
import { Sapphire, type Infer } from '@ascendance-hub/sapphire-core'
const a = new Sapphire()
const userSchema = a.object({
name: a.string().min(1),
email: a.string().email(),
age: a.number().int().min(0).optional(),
})
type User = Infer<typeof userSchema>
// User = { name: string; email: string; age?: number | undefined }
Infer<typeof userSchema> is the output type โ the value you get back from parse. InferInput<typeof userSchema> would give you the input type (relevant once you start using .default()).
Parse / safeParse
Every field exposes parse (throws SapphireValidationError on failure) and safeParse (returns a discriminated union, never throws):
const user = userSchema.parse({
name: 'Ada',
email: 'ada@example.com',
age: 36,
})
// user is typed as { name: string; email: string; age?: number | undefined }
When the input is invalid, safeParse returns { success: false, error }, where error.issues is an array describing every problem:
const result = userSchema.safeParse({
name: '',
email: 'not-an-email',
age: -1,
})
if (!result.success) {
// result.error.issues is an array of { path, code, message }
for (const issue of result.error.issues) {
// e.g. { path: ['email'], code: 'format', message: '...' }
// issue.path: where in the value the problem is
// issue.code: a stable string from the IssueCode union
// issue.message: the resolved message (string or object)
void issue
}
}
Each ValidationIssue carries three fields you care about:
pathโ a(string | number)[]pointing to the offending value (e.g.['address', 'zip']).codeโ a stable string from theIssueCodeunion (e.g.invalid_type,min_length,format). Use this for branching, not the message.messageโ the resolved message (string or object). Resolution walks the per-call โ per-rule โ field โ instance โ built-in hierarchy.
Add an adapter
Adapters are not auto-registered. Register the one you want once in your applicationโs entry point, then call .getSchema() on any field to produce its adapted output:
import mongoose from 'mongoose'
import { Sapphire, registerAdapter } from '@ascendance-hub/sapphire-core'
import { toMongooseSchema } from '@ascendance-hub/sapphire-mongoose'
registerAdapter('mongoose', toMongooseSchema)
const a = new Sapphire({ defaultAdapter: 'mongoose' })
const userSchema = a.object({
name: a.string().min(1),
email: a.string().email(),
age: a.number().int().min(0).optional(),
})
const mongoSchema = userSchema.getSchema() as mongoose.Schema
// mongoSchema is a real mongoose.Schema, ready for mongoose.model(...)
registerAdapter(name, fn) puts your adapter under a name; getSchema(name?, options?) resolves the fieldโs IR (SapphireSchemaNode) through that adapter. When the Sapphire instance has a defaultAdapter, getSchema() uses it; otherwise pass the name explicitly: userSchema.getSchema('mongoose').
The adapter registry is process-global: register each adapter once, at application startup, before any getSchema call. Registering one adapter never blocks another โ a multi-database app registers 'mongoose', 'drizzle' and the rest side by side and emits all of them from one schema definition.
The same pattern works for the JSON Schema and Drizzle adapters โ JSON Schema needs no options, Drizzle requires at minimum a dialect:
import { toJsonSchema } from '@ascendance-hub/sapphire-json-schema'
registerAdapter('json-schema', toJsonSchema)
const jsonSchema = userSchema.getSchema('json-schema')
import { toDrizzleSchema } from '@ascendance-hub/sapphire-drizzle'
registerAdapter('drizzle', toDrizzleSchema)
// Drizzle needs adapter options โ pass them as the second argument:
const usersTable = userSchema.getSchema('drizzle', { dialect: 'pg' })
[!NOTE] The Drizzle adapter requires
{ dialect: 'pg' | 'mysql' | 'sqlite' }. CallinggetSchema('drizzle')without options throws a clear error. See adapters/drizzle.md for the full options surface (tableName,primaryKey,tables).
Next steps
- Concepts โ Overview โ the mental model and where Sapphire fits.
- Concepts โ Fields and modifiers โ full reference for every field and the modifiers it supports.
- Adapters โ Mongoose โ deep-dive on the Mongoose adapter, including escape hatches and IR mapping.