Skip to content

Instantly share code, notes, and snippets.

@TonyGravagno
Last active March 21, 2024 09:31
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save TonyGravagno/2b744ceb99e415c4b53e8b35b309c29c to your computer and use it in GitHub Desktop.
Save TonyGravagno/2b744ceb99e415c4b53e8b35b309c29c to your computer and use it in GitHub Desktop.
Create a default object from a Zod schema
import { z } from 'zod'
/**
* @summary Function returns default object from Zod schema
* @version 23.05.15.2
* @link https://gist.github.com/TonyGravagno/2b744ceb99e415c4b53e8b35b309c29c
* @author Jacob Weisenburger, Josh Andromidas, Thomas Moiluiavon, Tony Gravagno
* @param schema z.object schema definition
* @param options Optional object, see Example for details
* @returns Object of type schema with defaults for all fields
* @example
* const schema = z.object( { ... } )
* const default1 = defaultInstance<typeof schema>(schema)
* const default2 = defaultInstance<typeof schema>(
* schema,{ // toggle from these defaults if required
* defaultArrayEmpty: false,
* defaultDateEmpty: false,
* defaultDateUndefined: false,
* defaultDateNull: false,
* } )
*/
export function defaultInstance<T extends z.ZodTypeAny>(
schema: z.AnyZodObject | z.ZodEffects<any>,
options: object = {}
): z.infer<T> {
const defaultArrayEmpty = 'defaultArrayEmpty' in options ? options.defaultArrayEmpty : false
const defaultDateEmpty = 'defaultDateEmpty' in options ? options.defaultDateEmpty : false
const defaultDateUndefined = 'defaultDateUndefined' in options ? options.defaultDateUndefined : false
const defaultDateNull = 'defaultDateNull' in options ? options.defaultDateNull : false
function run(): z.infer<T> {
if (schema instanceof z.ZodEffects) {
if (schema.innerType() instanceof z.ZodEffects) {
return defaultInstance(schema.innerType(), options) // recursive ZodEffect
}
// return schema inner shape as a fresh zodObject
return defaultInstance(z.ZodObject.create(schema.innerType().shape), options)
}
if (schema instanceof z.ZodType) {
let the_shape = schema.shape as z.ZodAny // eliminates 'undefined' issue
let entries = Object.entries(the_shape)
let temp = entries.map(([key, value]) => {
let this_default =
value instanceof z.ZodEffects ? defaultInstance(value, options) : getDefaultValue(value)
return [key, this_default]
})
return Object.fromEntries(temp)
} else {
console.log(`Error: Unable to process this schema`)
return null // unknown or undefined here results in complications
}
function getDefaultValue(dschema: z.ZodTypeAny): any {
console.dir(dschema)
if (dschema instanceof z.ZodDefault) {
if (!('_def' in dschema)) return undefined // error
if (!('defaultValue' in dschema._def)) return undefined // error
return dschema._def.defaultValue()
}
if (dschema instanceof z.ZodArray) {
if (!('_def' in dschema)) return undefined // error
if (!('type' in dschema._def)) return undefined // error
// return empty array or array with one empty typed element
return defaultArrayEmpty ? [] : [getDefaultValue(dschema._def.type as z.ZodAny)]
}
if (dschema instanceof z.ZodString) return ''
if (dschema instanceof z.ZodNumber || dschema instanceof z.ZodBigInt) {
let value = dschema.minValue ?? 0
return value
}
if (dschema instanceof z.ZodDate) {
let value = defaultDateEmpty
? ''
: defaultDateNull
? null
: defaultDateUndefined
? undefined
: (dschema as z.ZodDate).minDate
return value
}
if (dschema instanceof z.ZodSymbol) return ''
if (dschema instanceof z.ZodBoolean) return false
if (dschema instanceof z.ZodNull) return null
if (dschema instanceof z.ZodPipeline) {
if (!('out' in dschema._def)) return undefined // error
return getDefaultValue(dschema._def.out)
}
if (dschema instanceof z.ZodObject) {
return defaultInstance(dschema, options)
}
if (dschema instanceof z.ZodAny && !('innerType' in dschema._def)) return undefined // error?
return getDefaultValue(dschema._def.innerType)
}
}
return run()
}
@TonyGravagno
Copy link
Author

Ref colinhacks/zod#1953

This function was originally created by other Zod user/developers. Credit for the core goes to them. No claim of origination is implied.

I added value and am posting here for ongoing updates - better than edits to a discussion thread.

Please advise if I've neglected any professional courtesy, attribution, or professional expectation.

@TonyGravagno
Copy link
Author

TonyGravagno commented May 15, 2023

The current version adds support for Symbol, Boolean, Null, and most importantly - Date.

Options have also been added to support defaulting Date to either empty string, null, or undefined.

@TonyGravagno
Copy link
Author

Improved comments, set version @Version 23.05.15.2 (date.seq)

@mpint
Copy link

mpint commented Jul 20, 2023

Hi there, really appreciate you moving this default value utility forward. I have a schema that doesn't seem to work with this version.

export const fullUserSchema = z.object({
  user: userSchema,
  roles: roleSchema.array().default([]),
  keyPairs: userKeyPairSchema.array().default([]),
  accounts: userAccountMinSchema.array().default([]),
})

The key thing here is the array()s of subschemas that is throwing the following error:

query.js:358 TypeError: Cannot read properties of undefined (reading '_def')
    at getDefaultValue (api-utils.ts:196:3)
    at getDefaultValue (api-utils.ts:196:3)
    at api-utils.ts:146:14
    at Array.map (<anonymous>)
    at run (api-utils.ts:141:11)
    at defaultInstance (api-utils.ts:196:3)
    at api-utils.ts:196:3

@TonyGravagno
Copy link
Author

I understand. I'll look at it. Thanks.

@TonyGravagno
Copy link
Author

I don't doubt the error is occurring there and I'd like to work with you to get this resolved.
But I'm not able to duplicate the error yet.

First, just an array without default []

  public static schema = z.object({
    users: Person.Schema.array()
  })

Result:

Object { users: (1) […] }
​​> users: Array [ {…} ]
​​​> 0: Object { firstName: "Bob", lastName: "Jones", birthDate: Date Thu Jul 20 2023 11:14:34 GMT-0700 (Pacific Daylight Time) }
​​​> length: 1

Now setting default for 'users' that is an array of Persons:

  public static schema = z.object({
    users: Person.Schema.array().default( [] )
  })

Result:

users: Array []
​​​> length: 0

Array yes, empty yes, error no...

Perhaps the issue is due to something deeper in your schema, in the wrapping query.js, or something else in your api-utils.ts which seems to contain defaultInstance ?

More context will help.
For example, which line in this gist now corresponds to line 196 in your module?
And, could you try to eliminate one of the fields (roles, keyPairs, accounts) so that we can get a hint about which one it's choking on, if maybe not just the first?

HTH

@mpint
Copy link

mpint commented Jul 20, 2023

Hi @TonyGravagno, thanks for the quick responses. I found the issue although maybe you can understand the reason better than I can.

FWIW, I was seeing the error in my initial message with "zod": "^3.21.4" and schema as simple as:

export const fullUserSchema = z.object({
  user: z.string().default('johnny'),
})

The fix appears to be adding an existence check on L93 because dschema appears to sometimes be undefined.

if (
        !dschema?._def ||
        (dschema instanceof z.ZodAny && !('innerType' in dschema._def))
      )
        return undefined // error?

      return getDefaultValue(dschema._def.innerType)

Adding this check gives me the desired set of defaults for my schema

{
    "user": {
        "firstName": "",
        "lastName": "",
        "tenantId": "",
    },
    "roles": [],
    "keyPairs": [],
    "accounts": []
}

Thanks for this awesome function :)

@TonyGravagno
Copy link
Author

Got it. Glad you worked through it. I'll look at integrating this info into the code.

Attribution

With respect for those who contributed to this code, it's really a big hack and at some point I or someone else will suggest something much better - with care not to break the calling signature.

Colin has written a lot on the topic, and has put a lot of thought into enhancing defaults and transformations, and that insight should be incorporated into a utility like this - maybe even obsoleting it's raison d'être.
( Blog ... RFC )

With this code one can expect issues when assumptions are made about pre- and post-refinement values, defaults established pre- and post-transformations, and maybe with more deeply nested schema.

All that said, until someone points to something better, so far it seems to be an adequate temporary solution to get us through the day. Sometimes that's all I need.

@mpint
Copy link

mpint commented Jul 20, 2023

Yeah, in my experience with Zod, failed schema validation not falling back to defaults has been the most surprising and disappointing experience. In hindsight, it makes sense that defaults aren't applied but its an easy false assumption to fall into, and one would hope for an easy way to enable that functionality.

Hoping a reasonable solution for this makes it into zod@3 core!

@iSplasher
Copy link

Would it be possible to support a ZodDefault object as input?

@criscola
Copy link

Should be merged into zod!

@TonyGravagno
Copy link
Author

@iSplasher - If the schema has a ZodDefault, that's used, otherwise it continues on to make a guess.
See current line 57. Does that help?

@TonyGravagno
Copy link
Author

@criscola - Thanks but from my recent note:

With this code one can expect issues when assumptions are made about pre- and post-refinement values, defaults established pre- and post-transformations, and maybe with more deeply nested schema.

I don't think the majority of Zod users would want gaps like that in the official solution, though those of us who use this gist accept the limitations.

I've been doing a lot of Zod work but nothing that can be contributed to this gist recently.

@iSplasher
Copy link

@iSplasher - If the schema has a ZodDefault, that's used, otherwise it continues on to make a guess. See current line 57. Does that help?

Hi, almost!
This won't get to reach that far:

const def = z.object(...).default(...)

Because this fails:

      let the_shape = schema.shape as z.ZodAny // eliminates 'undefined' issue

@criscola
Copy link

@criscola - Thanks but from my recent note:

With this code one can expect issues when assumptions are made about pre- and post-refinement values, defaults established pre- and post-transformations, and maybe with more deeply nested schema.

I don't think the majority of Zod users would want gaps like that in the official solution, though those of us who use this gist accept the limitations.

I've been doing a lot of Zod work but nothing that can be contributed to this gist recently.

Ah, I see, I just got bitten by this when I had to apply a refine to an array of objects.

@TonyGravagno
Copy link
Author

Yeah, that's one of the big challenges here. Getting defaults from "static" functions is easy. It's the dynamic transformations, conditional handling, and refinements that occur during validation that might not be possible to nail down in a default. Or better stated, if schema is processed dynamically, how can we deduce a single static default?

I think the code here can be improved but I also think expectations of defaults need to be balanced with an obligation to provide a tool like this with the hints required make it work effectively. Primarily that means adding a .default() onto every field.

@TonyGravagno
Copy link
Author

@iSplasher - can you post a full z.object that I can work on as a sample? Clarify what you'd like to see as a default with the pattern you have so that I get a good feel for the goal. Thanks.

@iSplasher
Copy link

iSplasher commented Aug 22, 2023

@iSplasher - can you post a full z.object that I can work on as a sample? Clarify what you'd like to see as a default with the pattern you have so that I get a good feel for the goal. Thanks.

Hi, sure
I'm talking about this:

const schema =  z.object({
    key: z.string()
});

const defs = defaultInstance(schema.default({ key: 'test' }))

This fails.

@TonyGravagno
Copy link
Author

@iSplasher I don't think the .default method works on a z.object like that. It's intended to work on individual properties, and I don't think it's intended to be applied to the object afterward. But now your original question makes sense to me:

Would it be possible to support a ZodDefault object as input?

You're trying to amend a schema with defaults, outside of the initial z.object creation. Can you confirm the validity of that without using this utility? If that is verified as being valid I guess this utility will need to be modified to accommodate.

For reference, the normal way to do what you're doing there is :

const schema =  z.object({
    key: z.string().default('test')
});
const defs = defaultInstance<typeof schema>(schema)

Please post here about your intent, and your (verified please) understanding of what is supposed to happen, and let's see if we can ensure that this works correctly.

@TonyGravagno
Copy link
Author

There is another important point here. Note from the comments at the top of the code:

const defs = defaultInstance<typeof schema>(schema)

The "typeof schema" really should be there. Zod behaves differently when types are not explicitly specified. I have not looked closely at this but I know in my app code there is a difference when the schema is inferred, maybe as z.ZodTypeAny rather than an explicit type ( from typeof or from z.infer ). That is, T yields a less specific type when it is not explictly stated, and you might find the final type needs to be recast to typeof schema to get the exact object type that you desire.

I also note from looking at this code now, that type "T" isn't explicitly used in the function signature:

function defaultInstance<T extends z.ZodTypeAny>(
  schema: z.AnyZodObject | z.ZodEffects<any>,
  options: object = {}
): z.infer<T> {

I'm considering modifying that to the following:

function defaultInstance<T extends z.AnyZodObject | z.ZodEffects<any>>(
  schema: T,
  options: object = {}
): z.infer<T> {

This should ensure a result that really is the correct type. Although for run-time, it might be fine if the final object just conforms to the proper shape - and it does that now.

I'll do some testing with this but it would help if anyone else here can also play with this and comment if it makes a difference in your results.

Thanks.

@iSplasher
Copy link

Please post here about your intent, and your (verified please) understanding of what is supposed to happen, and let's see if we can ensure that this works correctly.

We're using zod with react-hook-form and want to provide defaults to the forms while keeping our schema reusable.
In one part of our code base we do

schema.default({ key: 'some default' })

and another

schema.default({ key: 'other default' })

It's true that the resulting schema won't make use of the defaults, however, like this, we get automatic type support while keeping the defaults close to where they're needed :)

I took a shot at the implementation:

export function defaultInstance<T extends z.ZodTypeAny>(
  schema: z.AnyZodObject | z.ZodDefault<any> | z.ZodEffects<any>,
  options: object = {}
): z.infer<T> {

  const defaultArrayEmpty = 'defaultArrayEmpty' in options ? options.defaultArrayEmpty : false
  const defaultDateEmpty = 'defaultDateEmpty' in options ? options.defaultDateEmpty : false
  const defaultDateUndefined = 'defaultDateUndefined' in options ? options.defaultDateUndefined : false
  const defaultDateNull = 'defaultDateNull' in options ? options.defaultDateNull : false

  function run(): z.infer<T> {
    if (schema instanceof z.ZodEffects) {
      if (schema.innerType() instanceof z.ZodEffects) {
        return defaultInstance(schema.innerType(), options) // recursive ZodEffect
      }
      // return schema inner shape as a fresh zodObject
      return defaultInstance(z.ZodObject.create(schema.innerType().shape), options)
    }

    if (schema instanceof z.ZodDefault) {
        const defValues = schema._def.defaultValue()
        const shape = schema._def.innerType._def.shape

        const temp = Object.entries(shape).map(([key, value]) => {
            if (defValues[key] !== undefined) {
                return [key, defValues[key]]
            }
            else if (value instanceof z.ZodEffects || value instanceof z.ZodDefault) {
                return [key, defaultInstance(value as any, options)]
            } else {
                return [key, getDefaultValue(value as any)]
            }
          })


        return {
            ...Object.fromEntries(temp),
            ...defValues
        }
    }


    if (schema instanceof z.ZodType) {
      let the_shape = schema.shape as z.ZodAny // eliminates 'undefined' issue
      let entries = Object.entries(the_shape)
      let temp = entries.map(([key, value]) => {
        let this_default =
          value instanceof z.ZodEffects ? defaultInstance(value, options) : getDefaultValue(value)
        return [key, this_default]
      })
      return Object.fromEntries(temp)
    } else {
      console.error(`Error: Unable to process this schema`)
      return null // unknown or undefined here results in complications
    }

    function getDefaultValue(dschema: z.ZodTypeAny): any {
      if (dschema instanceof z.ZodDefault) {
        if (!('_def' in dschema)) return undefined // error
        if (!('defaultValue' in dschema._def)) return undefined // error
        return dschema._def.defaultValue()
      }
      if (dschema instanceof z.ZodArray) {
        if (!('_def' in dschema)) return undefined // error
        if (!('type' in dschema._def)) return undefined // error
        // return empty array or array with one empty typed element
        return defaultArrayEmpty ? [] : [getDefaultValue(dschema._def.type as z.ZodAny)]
      }
      if (dschema instanceof z.ZodString) return ''
      if (dschema instanceof z.ZodNumber || dschema instanceof z.ZodBigInt) {
        let value = dschema.minValue ?? 0
        return value
      }
      if (dschema instanceof z.ZodDate) {
        let value = defaultDateEmpty
          ? ''
          : defaultDateNull
          ? null
          : defaultDateUndefined
          ? undefined
          : (dschema as z.ZodDate).minDate
        return value
      }
      if (dschema instanceof z.ZodSymbol) return ''
      if (dschema instanceof z.ZodBoolean) return false
      if (dschema instanceof z.ZodNull) return null
      if (dschema instanceof z.ZodPipeline) {
        if (!('out' in dschema._def)) return undefined // error
        return getDefaultValue(dschema._def.out)
      }
      if (dschema instanceof z.ZodObject) {
        return defaultInstance(dschema, options)
      }
      if (dschema instanceof z.ZodAny && !('innerType' in dschema._def)) return undefined // error?
      return getDefaultValue(dschema._def.innerType)
    }
  }
  return run()
}

@TonyGravagno
Copy link
Author

Thanks for the update! I'll check through it as soon as I can, and if it doesn't seem to break anything I'll replace the OP.

@yamatohagi
Copy link

Has this been resolved?

@man-trackunit
Copy link

Would it make sense/possible to abstract it to also work for .catch()?

@FondueBug
Copy link

Thanks for your contribution, I really appreciate it.

@TonyGravagno
Copy link
Author

Update of ... non updates. I'm sorry that I haven't had time to come back to enhance this code. I was hoping that by now there would be a solution implemented in Zod, and have been holding out on continuing updates here. I'll hold out a bit longer. I haven't been active in my apps that use Zod recently so I'm a bit rusty on all of this.

The code here isn't ideal. There are a number of faults in more complex use cases. Here is my suggested approach to addressing this topic:

  • Watch this gist, but don't count on it being updated or for generous submissions to be incorporated.
  • Watch colinhacks/zod#1953
  • Be aware that any system that provides a default value for general use cases may not be ideal for a large number of specific use cases.
  • Get lots of sleep. Drink lots of coffee. Be good to yourself and others.
  • Have a great day.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment