Schemas as Single Sources of Truth
import { z, schema } from '@green-stack/schemas'
In the Project Structure docs, we talked about how predictable patterns in folders and files can help you move faster through automation.
This guide explains how Zod based schemas can help you gain even more speed through a predictable way of defining your data shapes, when paired with tooling built around it:
A core feature of this universal starterkit is taking what works and making it better. This is why we invented schema()
as a tiny wrapper around zod’s z.object()
. You can use it to define your datastructures just once for the entire monorepo.
zod
is a schema validation library built with Typescript in mind. By extending it withschema()
, we can leverage its powerful features to create single sources of truth for GraphQL, API handlers, Database Models and even automatic component docs.
Why Single Sources of Truth?
Think of all the places you may need to (re)define the shape of data.
- ✅ Types
- ✅ Validation
- ✅ DB models
- ✅ API inputs & outputs
- ✅ Form state
- ✅ Documentation
- ✅ Mock & test data
- ✅ GraphQL schema defs
Quite an extensive list for what is essentially describing the same data.
The problem
Generally speaking, you never want to define your datastructures more than once. Not only is it redundant and a pain to do, it’s also a recipe for disaster.
If you need to change something, you have to remember to change it in all the places. If at any point you forget to do that, then you risk your datastructures getting out of sync. When that happens, it will likely lead to outdated editor hints or docs at best, and bugs or even crashes at worst.
The solution
You can use schema()
to build out the shape of our data in one go. The resulting object will enable us to create other definitions from it for (e.g.) GraphQL and more. Meaning we can avoid ever declaring it again.
This is a huge win for maintainability and developer experience, as it avoids the need to keep it all in sync. No more redeclaring the same data shape for all your component props, database models or function args / responses.
Building Schemas with Zod
Let’s have a look at how zod
and schema()
defs translate to Typescript types: 👇
Defining Primitives
const someString = z.string() // -> string
const someNumber = z.number() // -> number
const someBoolean = z.boolean() // -> boolean
const someDate = z.date() // -> Date
Defining Objects
An object in this sense is a key-value pair, often used to represent the shape of a data “unit”:
some-workspace
└── /schemas/... # <- Single sources of truth
└── User.ts # <- e.g. User schema
export const User = schema('User', {
// Requires a name value (☝️) to port to other formats later, best to keep the same
// Zod can help you go even narrower than typescript
name: z.string().min(2), // <- e.g. Needs to be a string with at least 2 letters
age: z.number().min(18), // <- e.g. Age must be a number of at least 18
// Just like TS, it can help you indicate fields as optional
isAdmin: z.boolean().default(false), // <- Marked optional, defaults to false
birthdate: z.Date().nullish(), // = same as calling .nullable().optional()
})
Extracting Types
The main thing you’ll want to use your schemas for is to hard-link your validation with your types.
You can extract the type from the schema using z.infer()
:
// Extract type from the schema and export it as a type alias
export type User = z.infer<typeof User>
⬇⬇⬇
// {
// name: string,
// age: number,
// isAdmin?: boolean,
// birthDate?: Date | null,
// }
Validating inputs
You can use the .parse()
method to validate inputs against the schema:
// Call .parse() on the whole User schema...
const newUser = User.parse(someInput) // <- Auto infers 'User' type if valid
// ...or validate idividual fields by using '.shape' 👇
User.shape.age.parse("Invalid - Not a number")
// Throws => ZodError: "Expected a number, recieved a string."
// Luckily, TS will already catch this in your editor ( instant feedback 🙌 )
If a field’s value does not match the schema, it will throw a ZodError
:
try {
// 🚧 Will fail validation
const someNumber = z.number().parse("Not a number")
} catch (error) { // ⬇⬇⬇
/* Throws 'ZodError' with a .issues array:
[{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: [],
message: 'Expected number, received string',
}]
*/
}
Custom Errors
You can provide custom error messages by passing the message
prop:
const NumberValue = z.number({ message: 'Please provide a number' })
// Throws => ZodError: [{ message: "Please provide a number", ... })
You can provide custom error messages for specific validations:
const MinimumValue = z.number().min(10, { message: 'Value must be at least 10' })
// Throws => ZodError: [{ message: "Value must be at least 10", ... })
const MaximumValue = z.number().max(100, { message: 'Value must be at most 100' })
// Throws => ZodError: [{ message: "Value must be at most 100", ... })
Advanced Types
const Task = schema('Task', {
// Enums
status: z.enum(['draft', 'published', 'archived']),
// Arrays
tags: z.array(z.string()),
// Tuples
someTuple: z.tuple([z.string(), z.number()]),
})
When extracting the type with type Task = z.infer<typeof Task>
:
// {
// status: 'draft' | 'published' | 'archived',
// tags: string[],
// someTuple: [string, number],
// }
Anything you can define the shape of in Typescript, you can define in Zod:
Check zod.dev for the full list of what you can define with zod.
Reusing and extending schemas
It can happen that you need to differentiate between two similar data shapes, for example, needing to expand on an existing shape.
- User.ts
- AdminUser.ts ← extension of 'User'
You can add new fields by calling .extendSchema()
on the original schema:
// Extend the User schema
const AdminUser = User.extendSchema('AdminUser', {
isAdmin: z.boolean().default(true),
})
type AdminUser = z.infer<typeof AdminUser>
// {
// name: string,
// age: number,
// birthDate?: Date | null,
//
// isAdmin?: boolean, // <- New field added
// }
You will need to provide a new name for the extended schema. This ensures there is no conflict with the original one when we port it to other formats.
.pickSchema()
- select fields
Similar to extending, you can create a new schema by picking specific fields from another:
const PublicUser = User.pickSchema('PublicUser', {
name: true,
age: true, // <- Only these fields will be included
})
type PublicUser = z.infer<typeof PublicUser>
// {
// name: string,
// age: number,
// }
.omitSchema()
- remove fields
The reverse is also possible by removing certain fields from another. The new schema will have all fields from the original, except the ones you specify:
const PublicUser = User.omitSchema('PublicUser', {
birthDate: true, // <- Will be missing in the new schema
})
type PublicUser = z.infer<typeof PublicUser>
// {
// name: string,
// age: number,
// }
Nesting and Collections
You can nest schemas within each other.
This is useful when you need to represent a more complex data shape
- User.ts
- AdminUser.ts
- PublicUser.ts
- Team.ts ← Contains 'User' members
For example, sometimes you need to represent a collection of specific data:
const Team = schema('Team', {
members: z.array(User), // <- Pass the 'User' schema to z.array()
teamName: z.string(),
})
type Team = z.infer<typeof Team>
// {
// teamName: string,
// members: {
// name: string,
// age: number,
// birthDate?: Date | null,
// }[]
// }
// ⬇⬇⬇ Which is in essense:
// {
// teamName: string,
// members: User[]
// }
Defaults and optionality
You can mark fields as optional or provide default values by using either:
.optional()
- to allowundefined
.nullable()
- to allownull
.nullish()
- to allow both
You can also use .default()
to provide a default value when the field isn’t passed in:
// Define a schema with optional and nullable fields
const User = schema('...', {
name: z.string().optional(), // <- Allow undefined
age: z.number().nullable(), // <- Allow null
birthData: z.date().nullish(), // <- Allow both
// Use .default() to make optional in args,
// but provide a default value when it IS undefined
isAdmin: z.boolean().default(false), // <- false
})
Transforming to other formats
With both types and validation in place, you can now transform your schemas to other formats. This is where the real power of schema()
comes into play.
Schema introspection
Before you can transform your schemas, you need to introspect them. This is done by calling .introspect()
on the schema:
const userShapeMetadata = User.introspect()
// ⬇⬇⬇ Lists the JSON representation of your data shape
{
"name": "User",
"zodType": "ZodObject", // <- Zod class it was built with, e.g. z.object()
"baseType": "Object", // <- e.g. "Array" / "Boolean" / "String" / ...
"schema": {
// ☝️ Nested properties will be listed under "schema", e.g. object fields
"name": {
"zodType": "ZodString",
"baseType": "String",
"isOptional": false, // in case of .optional() or .default()
},
"age": {
zodType: "ZodNumber",
baseType: "Number", ...
},
"birthDate": {
zodType: "ZodDate",
baseType: "Date", ...
}
}
}
Don’t worry though, you’re unlikely to have to do this manually in your day-to-day work.
A deep introspection API is what allows the kit to transform these zod schemas to other formats. Introspection is what enables schemas to serve as the Single Sources of Truth for all data shapes, making them quite portable.
Introspection metadata
This is all possible metadata you can extract with .introspect()
from a single schema field:
type Metadata<S> = {
// Essentials
name?: string, // <- The name you passed to schema(), e.g. 'User'
zodType: ZOD_TYPE,
baseType: BASE_TYPE,
// Optionality and defaults
isOptional?: boolean,
isNullable?: boolean,
defaultValue?: any$Unknown,
// Documentation
exampleValue?: any$Unknown,
description?: string,
minLength?: number,
maxLength?: number,
exactLength?: number,
minValue?: number,
maxValue?: number,
// Flags
isInt?: boolean,
isBase64?: boolean,
isEmail?: boolean,
isURL?: boolean,
isUUID?: boolean,
isDate?: boolean,
isDatetime?: boolean,
isTime?: boolean,
isIP?: boolean,
// Literals, e.g. z.literal()
literalValue?: any$Unknown,
literalType?: 'string' | 'boolean' | 'number',
literalBase?: BASE_TYPE,
// e.g. Nested schema field(s) to represent:
// - object properties (like meta for 'age' / 'isAdmin' / ...)
// - array elements
// - tuple elements
schema?: S,
// The actual Zod object, only included with .introspect(true)
zodStruct?: z.ZodType & { ... },
// Compatibility with other systems like databases & drivers
isID?: boolean,
isIndex?: boolean,
isUnique?: boolean,
isSparse?: boolean,
}
We’re only revealing this to show how it works under the hood. Again, you’re unlikely to need to use this directly in your day-to-day work.
Are you building your own tools that hook into the introspection result though?
Then you can use this generic metadata type to help provide type-safety:
import { Metadata } from '@green-stack/schemas'
A good example of how these schemas can be transformed through the introspection result is in the Automatic MDX Docs plugin:
MDX docs from Schemas
git merge with/automatic-docs
There’s two ways schemas are used to create docs:
- GraphQL schema comments provide hints in the GraphQL playground - at
/api/graphql
- MDX component docs generated from the prop schema, with interactive controls
No. 1 already works without any plugin when using our recommended way to do GraphQL
No. 2 happens automatically when running
npm run dev
after merging the docs plugin. (see demo)
For the best documentation experience, you’ll want to add some important metadata:
.describe()
- to add a description to the field.example()
- to provide an example value for previews.default()
- to provide a default value (fallback example value)
const UserProfileProps = schema('UserProfileProps', {
// Add a description to the field
name: z.string().describe('The name of the user'),
// Provide an example value
age: z.number().example(25),
// Add a description and example value
birthDate: z
.date()
.describe('User birthdate')
.example('1996-12-19'),
// Default values can be used as examples as well
isAdmin: z.boolean().default(false),
})
// Type alias
type UserProfileProps = z.infer<typeof UserProfileProps>
/* --- <UserProfile> --- */
export const UserProfile = (props: UserProfileProps) => {
// ...
}
The final step to link this schema to the component so it gets picked up by the docs is exporting the introspection result from the same file as the component:
some-workspace
└── /components/...
└── UserProfile.tsx # <- export `getDocumentationProps`
To match the expected format, you can use .documentationProps()
on the props schema, it will call .introspect()
internally to include the metadata we need for the docs:
// ...
/* --- Docs --- */
export const getDocumentationProps = UserProfileProps.documentationProps('UserProfile', {
// 🚧 All these options are optional:
valueProp: '...', // <- For form components, e.g. 'defaultValue', saved to docs URL state
onChangeProp: '...', // <- For form components, e.g. 'onTextChange', triggers save to URL state
// Alternative way to provide example props, e.g.:
exampleProps: {
name: 'John Doe',
age: 25,
birthDate: '1996-12-19',
isAdmin: false,
},
})
Think of getDocumentationProps
as a way to mark the component as documentable. It’s a convention that the docs plugin will look for when generating the MDX docs.
The end result will look like a Storybook-like documentation page for your component, with:
- ✅ Component name & import path
- ✅ Live preview of the component
- ✅ Props table with types, descriptions, examples, defaults
- ✅ Copyable component code for current prop settings
- ✅ Interactive controls to update both live preview and code
Check out a live example for the interactive Button component docs:
Plugin branches with Zod schema integrations
Out of the box:
npm run build:schema
- Builds yourschema.graphql
from zod schemasbridgedFetcher()
- Data fetcher that auto-scaffolds GraphQL query from zoduseFormState()
- Form state hook to provide typed form state and validation utilscreateSchemaModel()
- Createsmock
model until merging any DB driver plugin 👇
Interactive DB driver plugins:
with/mongoose
- zod to mongoosewith/supabase
- zod to supabasewith/prisma
- zod to prismawith/drizzle
- zod to drizzlewith/airtable
- zod to airtable
API plugins:
with/trpc
- Pair zod Data Bridges with resolvers to create tRPC handlers
Note that these plugins are only available in the paid version of the starterkit.
Schema generator
Like with all elements of our recommended way of working, there is a turborepo generator to help you create a schema in a specific workspace:
npm run gen add-schema
⬇⬇⬇
This will prompt you for a target workspace and name:
>>> Modify "your-project" using custom generators
? Where would you like to add this schema?
❯ features/app-core # -- importable from: '@app/core'
features/cv-page # -- importable from: 'cv-page'
features/links-page # -- importable from: 'links-page'
⬇⬇⬇
>>> Modify "your-project" using custom generators
? Where would you like to add this schema? # @app/core
? What is the schema name? # SomeData
? Optional description? # ... lorem ipsum, dolor ...
? Optional examples: Add common field definitions? # id, title
? Generate an integration for this schema? # form, dbModel
>>> Changes made:
• /features/@app-core/schemas/SomeData.ts # (add)
• /features/@app-core/schemas/index.ts # (append-last-line)
>>> Success!
⬇⬇⬇
@app-core
└── /schemas/...
└── SomeData.schema.ts
Though if you chose to also generate an integration, it might look like this instead:
@app-core
└── /schemas/...
└── SomeData.schema.ts
└── /hooks/...
└── useSomeData.ts # <- Form state hook using `useFormState()`
└── /models/...
└── SomeData.ts # <- `@db/driver` model using `createSchemaModel()`
Pretty powerful, right?
Disclaimer — on using z.union
and z.tuple
Since GraphQL and other systems might not natively support unions or tuples, it could be best to avoid them. We allow you to define them, but tranformations of these to other formats is considered experimental and potentially not fully type-safe.
You can always use z.array
and z.object
to represent the same data shapes.
e.g. instead of:
const someUnionField = z.union([z.string(), z.number()])
// TS: string | number
const someTupleField = z.tuple([z.string(), z.number()])
// TS: [string, number]
you might try this instead:
const SomeSchema = schema('SomeSchema', {
// Unions
someUnionFieldStr: z.string(), // TS: string
someUnionFieldNum: z.number(), // TS: number
// Tuples
someTupleField: schema('SomeTupleField', {
stringValue: z.string(), // TS: string
numberValue: z.number(), // TS: number
})
})
… which will work in GraphQL and other formats as well.
We’ve done our best to hack in experimental tuple and union support where possible. The choice is yours.
Further reading
- Zod’s official docs - zod.dev
- The Joy of Single Sources of Truth - Article
- Data Bridges for fetching - Starterkit Docs