Zod and the Joy of Single Sources of Truth
Thorr ⚡️ codinsonn.dev
Posted on May 29, 2024
If you love Typescript and haven't been living under a rock for the past 2 years, you've likely heard of Zod.
Zod is a Typescript-first schema validation library, aimed at maximum TS compatibility. It powers popular libraries like TRPC & has a big ecosystem around it
An important design goal is to provide type safety at runtime, not just build time.
The major strength of Zod is that it can extract types from your validation schema. For example, this is how you would define primitive data validators in Zod and extract the types
import { z } from 'zod'
// - Strings -
const StringValue = z.string()
type StringValue = z.infer<typeof StringValue> // => string
StringValue.parse("Hello World") // => ✅ inferred result = string
StringValue.parse(42) // => throws ZodError at runtime
Here's what it would look like for other primitive types:
// - Numbers -
const NumberValue = z.number()
type NumberValue = z.infer<typeof NumberValue> // => number
NumberValue.parse(42) // => ✅ inferred type = number
NumberValue.parse("15") // => throws ZodError at runtime
// - Booleans -
const BooleanValue = z.boolean()
type BooleanValue = z.infer<typeof BooleanValue> // => boolean
BooleanValue.parse(true) // => ✅ type = boolean
BooleanValue.parse("false") // => ZodError
// - Dates -
const DateValue = z.date()
type DateValue = z.infer<typeof DateValue> // => Date
DateValue.parse(new Date()) // => ✅ type = Date
DateValue.parse("Next Friday") // also throws
Already, Zod acts as a 'single source of truth' for validation and types.
To understand the power of Zod, you need to think of it as a single source of truth for your data structures:
// This is just a type
type SomeDatastructure = {
name: string
age: number
isStudent: boolean
birthdate: Date
}
// This provides both types AND validation
// + it can never get out of sync ✅
const SomeDatastructureSchema = z.object({
name: z.string(),
age: z.number(),
isStudent: z.boolean(),
birthdate: z.date()
})
In this article, I'll build up to more advanced use cases of Zod, and how it can be used as a single source of truth for not just types and validation, but any datastructure definition in your entire codebase.
Convert with Type Coercion
Sometimes, you don't want things like "42"
to be rejected when you're expecting a number.
You want to convert them to the correct type instead:
const NumberValue = z.coerce.number() // <= Prefix with .coerce to enable type coercion
const someNumber = NumberValue.parse("42") // => 42
From the zod.dev docs:
"During the parsing step, the input is passed through the String() function,
which is a JavaScript built-in for coercing data into strings."
You can do this for any primitive data type:
const StringValue = z.coerce.string() // When parsing -> String(input)
const someString = StringValue.parse(42) // => "42"
const DateValue = z.coerce.date() // -> new Date(input)
const someDate = DateValue.parse("2024-05-29") // => Date (Wed May 29 2024 00:00:00 GMT+0200)
const BooleanValue = z.coerce.boolean() // -> Boolean(input)
const someBoolean = BooleanValue.parse(0) // => false (falsy value)
// -!- Caveat: strings are technically truthy
const anotherBoolean = BooleanValue.parse("false") // => true
Validation Errors
So let's look at what happens when you try to parse invalid data:
try {
const someNumber = z.number().parse("This is not a number")
} catch (err) {
/* Throws 'ZodError' with a .issues array:
[ {
code: 'invalid_type',
expected: 'number',
received: 'string',
path: [],
message: 'Expected string, received number',
} ]
*/
}
From the zod.dev docs:
All validation errors thrown by Zod are instances of
ZodError
.
ZodError is a subclass of Error; you can create your own instance easily:
import * as z from "zod";
const MyCustomError = new z.ZodError([]);
Each ZodError has an
issues
property that is an array ofZodIssues
.
Each issue documents a problem that occurred during validation.
But, customizing error messages is easier than you might think:
Custom Error Messages
const NumberValue = z.number({ message: "Please provide a number" })
// => Throws ZodError [{ message: "Please provide a number", code: ... }]
const MinimumValue = z.number().min(10, { message: "Value must be at least 10" })
// => Throws ZodError [{ message: "Value must be at least 10", code: ... }]
const MaximumValue = z.number().max(100, { message: "Value must be at most 100" })
// => Throws ZodError [{ message: "Value must be at most 100", code: ... }]
Going beyond TS by narrowing types
The .min()
and .max()
methods on ZodNumber
from the previous examples are just the tip of the iceberg. They're a great example of what's possible beyond typescript-like type validation and narrowing.
For example, you can also use .min()
, .max()
and even .length()
on strings and arrays:
// e.g. TS can't enforce a minimum length (👇) on a string
const CountryNameValidator = z.string().min(4, {
message: "Country name must be at least 4 characters long"
})
// ... or an exact length on an array (👇) *
const PointValue3D = z.array(z.number()).length(3, {
message: "Coördinate must have x, y, z values"
})
*Though you could probably hack it together with Tuples 🤔
Advanced Types: Enums, Tuples and more
Speaking of, more advanced types like Tuples or Enums are also supported by Zod:
const PointValue2D = z.tuple([z.number(), z.number()]) // => [number, number]
const Direction = z.enum(["Left", "Right"]) // => "Left" | "Right"
Alternatively, for enums, you could provide an actual enum:
enum DirectionEnum {
Left = "Left",
Right = "Right"
}
const Direction = z.enum(DirectionEnum) // => DirectionEnum
If you want to use a zod enum to autocomplete options from, you can:
const Languages = z.enum(["PHP", "Ruby", "Typescript", "Python"])
type Languages = z.infer<typeof Languages>
// => "PHP" | "Ruby" | "Typescript" | "Python"
// You can use .enum for a native-like (👇) experience to pick options
const myFavoriteLanguage = Languages.enum.TypeScript // => "Typescript"
// ... which is the equivalent of:
enum Languages {
PHP = "PHP",
Ruby = "Ruby",
TypeScript = "Typescript",
Python = "Python"
}
const myFavoriteLanguage = Languages.TypeScript // => "Typescript"
There's more:
Zod also supports more advanced types like union()
, intersection()
, promise()
, lazy()
, nullable()
, optional()
, array()
, object()
, record()
, map()
, set()
, function()
, instanceof()
, promise()
and unknown()
.
However, if you're interested in learning the ins and outs, I highly recommend checking out the awesome Zod documentation, after you've read this article.
I won't go into further detail on these, as this is not just about Zod and how to use it.
It's about using schemas as single sources of truth.
Bringing it together in Schemas
Validating individual fields is great, but typically, you'll want to validate entire objects.
You can easily do this with z.object()
:
const User = z.object({
name: z.string(),
age: z.number(),
isStudent: z.boolean(),
birthdate: z.date()
})
Here too, the aliased schema type can be used for editor hints or instant feedback:
// Alias the Schema to the inferred type
type User = z.infer<typeof User> // <- Common pattern
const someUser: User = {
name: "John Doe",
age: 42,
isStudent: false,
birthdate: new Date("1980-01-01")
}
// => ✅ Yup, that satisfies the `User` type
Just like in Typescript, if you want to derive another user type from the User
schema, you can:
// Extend the User schema
const Admin = User.extend({
isAdmin: z.boolean()
})
type Admin = z.infer<typeof Admin>
// => { name: string, age: number, isStudent: boolean, birthdate: Date, isAdmin: boolean }
Other supported methods are pick()
and omit()
:
// Omit fields from the User schema
const PublicUser = User.omit({ birthdate: true })
type PublicUser = z.infer<typeof PublicUser>
// => { name: string, age: number, isStudent: boolean }
// Pick fields from the User schema
const MinimalUser = User.pick({ name: true, age: true })
type MinimalUser = z.infer<typeof MinimalUser>
// => { name: string, age: number }
Need to represent a collection of users...?
// Use a z.array() with the 'User' schema
const Team = z.object({
members: z.array(User) // <- Reusing the 'User' schema
teamName: z.string(),
})
type Team = z.infer<typeof Team>
// => { teamName: string, members: User[] }
... or maybe you want a lookup object?
// Use a z.record() for datastructure to e.g. to look up users by their ID
const UserLookup = z.record(z.string(), User)
// where z.string() is the type of the id
type UserLookup = z.infer<typeof UserLookup>
// => { [key: string]: User }
It's no understatement to say that Zod is already a powerful tool for defining data structures in your codebase.
But it can be so much more than that.
Why single sources of truth?
Think of all the places you might need to repeat certain field definitions:
- ✅ Types
- ✅ Validation
- ✅ Database Models
- ✅ API Inputs & Responses
- ✅ Form State
- ✅ Documentation
- ✅ Mocks & Test Data
- ✅ GraphQL schema & query definitions
Now think about how much time you spend to keep all of these in sync.
Not to mention the cognitive overhead of having to remember all the different places where you've defined the same thing.
I've personally torn my hair out over this a few times. I'd updated the types, input validation, edited my back-end code, the database model and the form, but forgot to update a GraphQL query and schema.
It can be a nightmare.
Now think of what it would be like if you could define your data structures in one place.
And have everything else derive from that.
Ecosystem Shoutouts
This is where the Zod ecosystem comes in:
Need to build APIs from zod schemas? 🤔
-
tRPC
: end-to-end typesafe APIs without GraphQL. -
@anatine/zod-nestjs
: using Zod in a NestJS project.
Need to integrate zod schemas within your form library? 🤔
-
react-hook-form
: Zod resolver for React Hook Form. -
zod-formik-adapter
: Formik adapter for Zod.
Need to transform zod schemas into other formats? 🤔
-
zod-to-json-schema
: Convert Zod schemas into JSON Schemas. -
@anatine/zod-openapi
: Converts Zod schema to OpenAPI v3.xSchemaObject
. -
nestjs-graphql-zod
: Generates NestJS GraphQL model
Disclaimer: For each example here, there are at least 4 to 5 more tools and libraries in the Zod ecosystem to choose from.
✅ Using just the ecosystem alone, you could already use Zod as a single source of truth for any data structure in your codebase.
🤔 The problem is that it is quite fragmented. Between these different installable tools, there are also different opinions on what an ideal API around it should look like.
Sometimes, the best code is the code you own yourself. Which is what I've been experimenting with:
What makes a good Single Source of Truth?
Let's think about it.
What essential metadata should be derivable from a single source of truth?
type IdealSourceOfTruth<T = unknown> = {
// -i- We need some basis to map to other formats
baseType: 'String' | 'Boolean' | ... | 'Object' | 'Array' | ...,
zodType: 'ZodString' | 'ZodBoolean' | ... | 'ZodObject' | ...,
// -i- We should keep track of optionality
isOptional?: boolean,
isNullable?: boolean,
defaultValue?: T,
// -i- Ideally, for documentation & e.g. GraphQL codegen
name?: string,
exampleValue?: T,
description?: string,
// -i- If specified...
minValue?: number,
maxValue?: number,
exactLength?: number,
regexPattern?: RegExp,
// -i- We should support nested introspection
// -i- For e.g. enums, arrays, objects, records, ...
schema?: Record<string, IdealSourceOfTruth> | IdealSourceOfTruth[]
// 👆 which would depend on the main `zodType`
}
Introspection & Metadata
What's missing in Zod?
At the core of a good single source of truth is a good introspection API:
Introspection is the ability to examine the structure of a schema at runtime to extract all relevant metadata from it and its fields.
Sadly, Zod doesn't have this out of the box.
There's actually a bunch of issues asking for it:
It seems like people really want a strong introspection API in Zod to build their own custom stuff around.
But what if it did have it?
Turns out, adding introspection to Zod in a way that feels native to it is not super hard:
Adding Introspection to Zod
All it takes is some light additions to the Zod interface:
Note: Editing the prototype of anything is typically dangerous and could lead to bugs or unexpected behavior. While we opted to do it to make it feel native to Zod, it's best to only use it for additions (in moderation), but NEVER modifications.
schemas.ts
import { z, ZodObject, ZodType } from 'zod'
/* --- Zod type extensions ----------------------------- */
declare module 'zod' {
// -i- Add metadata, example and introspection methods to the ZodObject type
interface ZodType {
metadata(): Record<string, any>, // <-- Retrieve metadata from a Zod type
addMeta(meta: Record<string, any>): this // <- Add metadata to a Zod type
example<T extends this['_type']>(exampleValue: T): this // <- Add an example value
introspect(): Metadata & Record<string, any> // <- Introspect a Zod type
}
// -i- Make sure we can name and rename Zod schemas
interface ZodObject<...> {
nameSchema(name: string): this // <- Name a Zod schema
extendSchema(name: string, shape: S): this // <- Extend a Zod schema & rename it
pickSchema(name: string, keys: Record<keyof S, boolean>): this // <- Pick & rename
omitSchema(name: string, keys: Record<keyof S, boolean>): this // <- Omit & rename
}
}
/* --- Zod prototype extensions ------------------------ */
// ... Actual implementation of the added methods, omitted for brevity ...
To check out the full implementation, see the full code on GitHub:
FullProduct.dev - @green-stack/core - schemas on Github
Using our custom introspection API
On that note, let's create a custom schema()
function in our custom schemas.ts
file to allow naming and describing single sources of truth without editing the z.object()
constructor directly:
// -i- To be used to create the initial schema
export const schema = <S extends z.ZodRawShape>(name: string, shape: S) => {
return z.object(shape).nameSchema(name)
}
// -i- Reexport `z` so the user can opt in / out to prototype extensions
// -i- ...depending on where they import from
export { z } from 'zod'
Which will allows us to do things like:
// -i- Opt into the introspection API by importing from our own `schemas.ts` file
import { z, schema } from './schemas'
// -i- Create a single source of truth by using the custom `schema()` function we made
const User = schema('User', {
name: z.string().example("John Doe"),
age: z.number().addMeta({ someKey: "Some introspection data" }),
birthdate: z.date().describe("The user's birthdate")
})
// -i- Alias the inferred type so you only need 1 import
type User = z.infer<typeof User>
To then retrieve all metadata from the schema:
const userDefinition = User.introspect()
This resulting object is a JS object, but could be stringified to JSON if required:
{
"name": "User",
"zodType": "ZodObject", // <- The actual Zod Class used
"baseType": "Object",
"schema": {
"name": {
"zodType": "ZodString",
"baseType": "String", // <- To transform to other formats
"exampleValue": "John Doe"
},
"age": {
"zodType": "ZodNumber",
"baseType": "Number",
"exampleValue": 42, // <- Good for docs
"someKey": "Some metadata" // <- Custom meta
},
"birthdate": {
"zodType": "ZodDate",
"baseType": "Date",
"description": "The user's birthdate" // <- Good for docs
}
}
}
Later we'll look at how we can use this metadata to generate other things like documentation, mocks, database models, etc.
Optionality and Defaults
Zod has native support for things like .nullable()
, .optional()
and .default()
.
Ideally, we'd be able to derive this information in introspection as well.
// Define a schema with optional and nullable fields
const User = schema('User', {
name: z.string().optional(), // <- Allow `undefined`
age: z.number().nullable(), // <- Allow `null`
birthdate: z.date().nullish(), // <- Allow `null` & `undefined`
isAdmin: z.boolean().default(false) // <- Allow `undefined` + use `false` if missing
})
This is actually one of the things that make adding a proper introspection API a bit difficult, since Zod wraps it's internal classes in some layers of abstraction:
// e.g. A nullish field with defaults might look like this:
ZodDefault(
ZodNullable(
ZodOptional(
ZodString(/* ... */)
)
)
)
You'd typically need to do some recursive layer unwrapping to get to the actual field definition of ZodString
in this case.
Luckily, our custom introspect()
method is set up to handle this for us and flattens the resulting metadata object into a more easily digestible format:
{
"name": "User",
"zodType": "ZodObject",
"baseType": "Object",
"schema": {
"name": {
"zodType": "ZodString",
"baseType": "String",
// 👇 As we wanted for our ideal Single Source of Truth
"isOptional": true
},
"age": {
"zodType": "ZodNumber",
"baseType": "Number",
// 👇 As we wanted for our ideal Single Source of Truth
"isNullable": true
},
"birthdate": {
"zodType": "ZodDate",
"baseType": "Date",
// 👇 Both optional and nullable due to `.nullish()`
"isOptional": true,
"isNullable": true
},
"isAdmin": {
"zodType": "ZodBoolean",
"baseType": "Boolean",
"isOptional": true,
// 👇 Also keeps track of the default value
"defaultValue": false
}
}
}
Now that we can extract metadata (optionality, defaults, examples, types, etc.) from our schemas, we can use these introspection results to generate other things.
Single Source of Truth Examples
Designing Database Models with Zod
For example, you want to generate a database model from your Zod schema.
You could build and apply a transformer function that takes the introspection result and generates a Mongoose model from it:
schemaToMongoose.ts
// Conceptual transformer function
import { z, createSchemaPlugin } from '@green-stack/schemas'
// Import mongoose specific stuff
import mongoose, { Schema, Model, Document, ... } from 'mongoose'
// --------------------------------------------------------
// -i- Conceptual meta/example code, not actual code
// --------------------------------------------------------
// Take a schema as input, infer its type for later use
export const schemaToMongoose = <S extends z.ZodRawShape>(schema: z.ZodObject<S>) => {
// Create a field builder for metadata aside from the base type
const createMongooseField = (mongooseType) => {
// Return a function that builds a Mongoose field from introspection data
return (fieldKey, fieldMeta) => {
// Build the base definition
const mongooseField = {
type: mongooseType,
required: !fieldMeta.isOptional && !fieldMeta.isNullable,
default: fieldMeta.defaultValue,
description: fieldMeta.description
}
// Handle special cases like enums
if (fieldMeta.zodType === 'ZodEnum') {
mongooseField.enum = Object.values(schemaConfig.schema)
}
// Return the Mongoose field definition
return mongooseField
}
}
// Build the mongoose schema definition by mapping metadata to Mongoose fields
const mongooseSchemaDef = createSchemaPlugin(
// Feed it the schema metadata from introspection
schema.introspect(),
// Map Zod types to Mongoose types
{
String: createMongooseField(String),
Number: createMongooseField(Number),
Date: createMongooseField(Date),
Boolean: createMongooseField(Boolean),
Enum: createMongooseField(String)
// ... other zod types ...
},
)
// Create mongoose Schema from SchemaDefinition
type SchemaDoc = Document & z.infer<z.ZodObject<S>> // <- Infer the schema type
const mongooseSchema: Schema<SchemaDoc> = new Schema(mongooseSchemaDefinition)
// Build & return the mongoose model
const schemaModel = model<SchemaDoc>(schema.schemaName, mongooseSchema) as SchemaModel
return schemaModel
}
Note: This is a conceptual example. The actual implementation would be a bit (but not much) more complex and handle more edge cases. I'll link you my actual implementations at the end.
To build a Mongoose model from a Zod schema, you'd use it like this:
// Import the schema and the transformer function
import { User } from './schemas'
import { schemaToMongoose } from './schemaToMongoose'
// Generate the Mongoose model from the Zod schema
const UserModel = schemaToMongoose(User) // <- Typed Mongoose model with `User` type
// Use the Mongoose model as you would any other
// It will apply & enforce the types inferred from the Zod schema
const user = new UserModel({
name: "John Doe",
age: 44,
birthdate: new Date("1980-01-01")
})
You could apply the same principle to generate other database modeling tools or ORMs like Prisma, TypeORM, Drizzle etc.
- Build a transformer function that takes in a schema
- Use introspection to extract metadata from the schema
- Map the metadata to some other structure (like a DB schema)
- Build the full database model from the transformed fields
- Assign the types inferred from the Zod schema to the database model
Now, if you need to change your database model, you only need to change the Zod schema. Typescript will automatically catch any errors in your codebase that need to be updated.
Pretty powerful, right?
Generating Docs
Many don't feel they have the time to write good documentation, even though they might see it as important.
What if you could attach the same Zod schema your React components use for types to generate docs from?
You'd need to parse the schema metadata and generate a markdown table or something like Storybook controls from it:
schemaToStorybookDocs.ts
import { z, schema } from '@green-stack/schemas'
/* --- Prop Schema ----------------- */
const GreetingProps = schema('GreetingProps', {
name: z.string().example("John"),
})
/* --- <Greeting /> ---------------- */
// -i- React component that uses the schema's type inference
export const Greeting = ({ name }: z.infer<typeof GreetingProps>) => (
<h1>Welcome back, {name}! 👋</h1>
)
/* --- Documentation --------------- */
// -i- Export the schema for Storybook to use
export const docSchema = GreetingProps
Note: You'll need some node script to scan your codebase with e.g. glob and build
.stories.mdx
files for you though. In those generated markdown files, you'll map
schemaToStorybookDocs.ts
// Similiar to the Mongoose example, but for Storybook controls
import { z, createSchemaPlugin } from '@green-stack/schemas'
/* --- schemaToStorybookDocs() ----- */
export const schemaToStorybookDocs = <S extends z.ZodRawShape>(schema: z.ZodObject<S>) => {
// ... Similar conceptual code to the Mongoose example ...
const createStorybookControl = (dataType, controlType) => (fieldKey, fieldMeta) => {
// ... Do stuff with metadata: defaultValue, exampleValue, isOptional, enum options etc ...
}
// Build the Storybook controls definition by mapping metadata to Storybook controls
const storybookControls = createSchemaPlugin(schema.introspect(),
{
// Map baseTypes to Storybook data (👇) & control (👇) types
Boolean: createStorybookControl('boolean', 'boolean'),
String: createStorybookControl('string', 'text'),
Number: createStorybookControl('number', 'number'),
Date: createStorybookControl('date', 'date'),
Enum: createStorybookControl('enum', 'select'), // <- e.g. Use a select dropdown for enums
// ... other zod types ...
},
)
// Return the Storybook controls
return storybookControls
}
Which, after codegen creates a .stories.mdx
file that uses schemaToStorybookDocs()
, might look something like this:
Demo: You can test out a full working example of Zod-powered Storybook docs here: codinsonn.dev fully generated Storybook docs (+ Github Source)
Resolvers and Databridges
My favorite example of building stuff around schemas is a concept I've dubbed a DataBridge
You can think of a
DataBridge
as a way to bundle the metadata around a resolver function with the Zod schemas of its input and output.
For example, if we have a resolver function that works in both REST or GraphQL :
healthCheck.bridge.ts
import { z, schema } from '@green-stack/core/schemas'
import { createDataBridge } from '@green-stack/core/schemas/createDataBridge'
/* --- Schemas ----------------------------------------- */
export const HealthCheckArgs = schema('HealthCheckArgs', {
echo: z.string().describe("Echoes back the echo argument")
})
// Since we reuse the "echo" arg in the response, we can extend (👇) from the input schema
export const HealthCheckResponse = HealthCheckArgs.extendSchema('HealthCheckResponse', {
alive: z.boolean().default(true),
kicking: z.boolean().default(true),
})
/* --- Types ------------------------------------------- */
export type HealthCheckArgs = z.infer<typeof HealthCheckArgs>
export type HealthCheckResponse = z.infer<typeof HealthCheckResponse>
/* --- DataBridge -------------------------------------- */
export const healthCheckBridge = createDataBridge({
// Bundles the input & output schemas with the resolver metadata
argsSchema: HealthCheckArgs,
responseSchema: HealthCheckResponse,
// API route metadata
apiPath: '/api/health',
allowedMethods: ['GET', 'POST', 'GRAPHQL'],
// GraphQL metadata
resolverName: 'healthCheck',
})
You might wonder why we're defining this in a file that's separate from the actual resolver function.
The reason is that we can reuse this DataBridge
on both the client and server side.
On the server side, image a wrapper createResolver()
function that takes a function implementation as a first argument and the DataBridge
as a second:
healthCheck.resolver.ts
// Helper function that integrates the resolver with the DataBridge
import { createResolver } from '@green-stack/core'
// Import the DataBridge we defined earlier
import { healthCheckBridge } from './healthCheck.bridge'
/* --- healthCheck() ----------------------------------- */
export const healthCheck = createResolver(({ args, parseArgs, withDefaults }) => {
// Handy helpers (☝️) from the DataBridge
// Typesafe Args from Databridge Input schema
const { echo } = args // <- { echo?: string }
// -- OR --
const { echo } = parseArgs() // <- { echo?: string }
// Check type match from the Databridge Output schema & apply defaults
return withDefaults({
echo,
alive: "", // <- Caught by Typescript, will have red squiggly line
kicking: undefined, // <- Defaults to `true`
})
}, healthCheckBridge)
// ☝️ Pass the DataBridge as the second argument to power types & resolver utils
Congrats. On the server side, you now have a fully typed resolver function that's bundled together with schemas of its input and output.
On the client, you could use just the DataBridge to build a REST fetcher or even build a GraphQL query from the bridge object, without conflicting with the server-side code.
While on the server, you could use the portable resolver bundle to generate your executable GraphQL schema from. Automagically.
Let's have a look at how that might be achieved.
Zod for simplifying GraphQL
Who thinks GraphQL is too complicated? 🙋♂
Let's bring our healthCheck
resolver to GraphQL. We'll need to:
- Generate GraphQL schema definitions from the
healthCheckBridge
- Generate a GraphQL query to call the
healthCheckBridge
query from the front-end
Again, we'll combine the introspection API for the DataBridge
with a transformer function to generate the GraphQL schema and query:
bridgeToSchema.ts
// Similiar to the Mongoose & Docs example, but for a GraphQL-schema
import { z, createSchemaPlugin } from '@green-stack/core/schemas'
// ...
// -i- We'll need to run this for both the Args & Response schemas individually
const storybookControls = createSchemaPlugin(schema.introspect(),
{
// Map baseTypes to GraphQL-schema definitions
Boolean: createSchemaField('Boolean'),
String: createSchemaField('String'),
Number: createSchemaField('Float'), // <- e.g. Float! or Float
Date: createSchemaField('Date'), // <- e.g. Date! or Date (scalar)
Enum: createSchemaField('String'), // <- e.g. String! or String
// ... other zod types ...
Object: createSchemaField(field.schemaName) // <- Will need some recursion magic for nested schemas
},
)
Once applied and loaded into your GraphQL server, if your mapper function is set up correctly, you should be able to propagate even your descriptions from z.{someType}.describe('...')
to your GraphQL schema:
We now no longer need to maintain separate GraphQL schema definitions.
It's all derived from the Zod args and response schemas in the resolver's DataBridge
.
With a bit of creativity, we could even generate the GraphQL query to call the healthCheck
resolver from the front-end:
healthCheck.query.ts
// -i- Like types & schemas, we can import & reuse the bridge client-side
import { healthCheckBridge } from './healthCheck.bridge'
import { renderBridgeToQuery } from '@green-stack/schemas'
// -i- Generate a GraphQL query from the DataBridge
const healthCheckQuery = renderBridgeToQuery(healthCheckBridge)
/* -i- Resulting in the following query string:
`query($args: HealthCheckArgs) {
healthCheck(args: $args) {
echo
alive
kicking
}
}`
*/
Which you could then use to build a typed fetcher function:
healthCheck.fetcher.ts
const healthCheckFetcher = (args: z.infer<typeof healthCheckBridge.argsSchema>) => {
// -i- Fetch the query with the args (inferred from the schema ☝️)
const res = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// ...headers,
},
body: JSON.stringify({
query: healthCheckQuery,
variables
}),
})
// -i- Return the response (which you can type from the response schema 👇)
return res.data.healthCheck as z.infer<typeof healthCheckBridge.responseSchema>
}
In the end, you get all the benefits of GraphQL while avoiding most extra boilerplate steps involved in maintaining it
Scaffolding Forms?
As a final example, many only think of validation libraries like Zod for integrating them with their forms.
But what if you could scaffold your forms from your Zod schemas on top?
UserRegistrationForm.tsx
return (
<SchemaForm
schema={User}
/* With single sources of truth, */
/* ...what's stopping you from coding like this? */
schemaToInputs={{
String: (fieldMeta) => <input type="text" {...fieldMeta} />,
Number: (fieldMeta) => <input type="number" {...fieldMeta} />,
Date: (fieldMeta) => <input type="date" {...fieldMeta} />,
Boolean: (fieldMeta) => <input type="checkbox" {...fieldMeta} />,
Enum: (fieldMeta) => <select {...fieldMeta}>
}}
/>
)
Even better DX with Codegen
All of this might still seem like a lot of manual linking between:
- Zod schemas
- Databridges & Resolvers
- Forms, Hooks, Components, Docs, APIs, Fetchers, etc.
But even this can be automated with cli tools if you want it to be:
>>> Modify "your-codebase" using turborepo generators?
? Where would you like to add this schema? # -> @app/core/schemas
? What will you name the schema? (e.g. "User") # -> UserSchema
? Optional description: What will this schema be used for? # -> Keep track of user data
? What would you like to generate linked to this resolver?
> ✅ Database Model (Mongoose)
> ✅ Databridge & Resolver shell
> ✅ GraphQL Query / Mutation
> ✅ GET / POST / PUT / DELETE API routes
> ✅ Typed fetcher function
> ✅ Typed `formState` hook
> ✅ Component Docs
The sweet thing is, you don't need to build this all from scratch anymore...
While there's no NPM package for this, where can you test working with Single Sources of Truth?
FullProduct.dev ⚡️ Universal App Starterkit
I've been working on a project called FullProduct.dev to provide a full-stack starterkit for building modern Universal Apps. Single sources of truth are a big part of that.
Like most time-saving templates, it will set you up with:
- Authentication
- Payments
- Scalable Back-end
- Essential UI components
❌ But those other boilerplates are usually just for the web, and often don't have extensive docs or an optional recommended way of working that comes with them.
🤔 You also often don't get to test the template before you buy it, and might still have to spend time switching out parts you don't like with ones you're used to.
Why FullProduct.dev ⚡️ ?
✅ Universal from the start - Bridges gap between Expo
& Next.js
✅ Write-once UI - Combines NativeWind
& React Native Web
for consistent look & feel on each device
✅ Recommended way of working - based on Schemas
, DataBridges
& Single Sources of Truth
✅ Docs and Docgen - Documentation that grows with you as you continue to build with schemas
✅ Built for Copy-Paste - Our way of working enables you to copy-paste full features between projects
✅ Customizable - Pick and choose from inspectable & diffable git-based plugins
While FullProduct.dev is still in active development, scheduled for a Product Hunt release in September 2024, you can already explore its core concepts in the source-available free demo or the previous iteration:
- Aetherspace GREEN stack starter - Previous iteration of FullProduct.dev (docs)
- Universal Base Starter - Base FullProduct.dev starterkit, with plugin PRs (docs)
The full working versions of the pseudo-code examples can also be found in these template repos:
To get started with them, use the Github UI to fork it and include all branches
If you want to check what our git-based plugins
might feel like:
Liked this article? 💚
Want to get regular updates on the FullProduct.dev ⚡️
project?
Or learn more about single sources of truth, data bridges or Zod?
You can find the links to the source code / blog / socials on codinsonn.dev ⚡️
Thank you for reading, hope you found it inspirational! 🙏
Posted on May 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.