DEV Community

Cover image for Zod and the Joy of Single Sources of Truth
Thorr ⚡️ codinsonn.dev
Thorr ⚡️ codinsonn.dev

Posted on • Updated on

Zod and the Joy of Single Sources of Truth

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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',
        } ]
    */
}
Enter fullscreen mode Exit fullscreen mode

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([]);
Enter fullscreen mode Exit fullscreen mode

Each ZodError has an issues property that is an array of ZodIssues.

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: ... }]
Enter fullscreen mode Exit fullscreen mode

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"
})
Enter fullscreen mode Exit fullscreen mode

*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"
Enter fullscreen mode Exit fullscreen mode

Alternatively, for enums, you could provide an actual enum:

enum DirectionEnum {
    Left = "Left",
    Right = "Right"
}

const Direction = z.enum(DirectionEnum) // => DirectionEnum
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode
// Pick fields from the User schema
const MinimalUser = User.pick({ name: true, age: true })

type MinimalUser = z.infer<typeof MinimalUser>
// => { name: string, age: number }
Enter fullscreen mode Exit fullscreen mode

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[] }
Enter fullscreen mode Exit fullscreen mode

... 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 }
Enter fullscreen mode Exit fullscreen mode

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.x SchemaObject.
  • 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`

}
Enter fullscreen mode Exit fullscreen mode

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:

Screenshot of multiple issues in the Zod repo asking for a strong introspection API

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

Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

To then retrieve all metadata from the schema:

const userDefinition = User.introspect()
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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(/* ... */)
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Databases 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
}
Enter fullscreen mode Exit fullscreen mode

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")
})
Enter fullscreen mode Exit fullscreen mode

You could apply the same principle to generate other database modeling tools or ORMs like Prisma, TypeORM, Drizzle etc.

  1. Build a transformer function that takes in a schema
  2. Use introspection to extract metadata from the schema
  3. Map the metadata to some other structure (like a DB schema)
  4. Build the full database model from the transformed fields
  5. 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

Documentation drives adoption

Many don't feel like 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Which, after codegen creates a .stories.mdx file that uses schemaToStorybookDocs(), might look something like this:

Storybook example with controls generated from a Zod schema

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',
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    },
)
Enter fullscreen mode Exit fullscreen mode

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:

GraphQL-schema example generated from our healthCheck example DataBridge

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
        }
    }`
*/
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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}>
        }}
    />
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


Banner Image showing FullProduct.dev logo in Love + Death + Robots style graphic next to the Zod logo

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
  • Email
  • 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:

Universal Base Starter docs screenshot

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

Image describing how to use Github UI to fork the template repo

If you want to check what our git-based plugins might feel like:

Image describing how to use Github UI to include all branches


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! 🙏

Top comments (0)