Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Allow generating an object with a full set of defaults that may not validate #213

Closed
abierbaum opened this issue Nov 4, 2020 · 7 comments

Comments

@abierbaum
Copy link

I am looking to use zod as a way to validate user configuration files for a command line utility. It looks like a great fit compared to what we are doing now, but I am having trouble with one aspect.

The workflow we use is:

  • Allocate an initial set of defaults in a nested object (note: this isn't fully filled in so won't validate the schema)
  • Pass to a user provided configuration function (ex. app.config.js) that extends the configuration
  • Fill in final settings based upon combination of user and defaults
  • Validate final config with schema. (looking for extra properties, validation checks, etc)

My problem is that in this model I don't see a way to use zod to create the initial defaults object. I know I could create it as a plain old javascript object separate from the schema, but I was hoping to take advantage of the .default() methods within the schema to have the schema be self describing without having a separate large defaults object.

So my hope was to make something like this work:

const ConfigSchema = z.object({
   /** The name to use. */
   name: z.string().default('blah'),

   /** Another nested part. */
   nested: z
      .object({
         value: z.number(),
         val2: z
            .string()
            .nullable()
            .refine((v) => v && v.length === 5, 'Must be length 5.'),
      })
      .default({
         value: 20,
         val2: null,
      }),
});

type ConfigType = z.infer<typeof ConfigSchema>;

describe('zod', () => {
   it('validates a good value', () => {});

   it('can provide defaults to override', () => {
      // We can build a set of default values that don't validate yet
      const default_results = ConfigSchema.safeParse({});  // <--- Need something here to just get defaults
      const cfg = default_results.data;

      expect(cfg.nested.val2).toEqual(null);

      // then: user modifies it with config overrides
      cfg.nested.val2 = '12345';

      // and: now it can validate
      const results = ConfigSchema.safeParse(cfg);
   });
});

Is anything like this possible or practical as a feature in zod? I think it is effectively a SafeParse that doesn't do any checks, but instead just sets the default values on the object passed in.

@colinhacks
Copy link
Owner

I'm not clear on what you're asking for exactly. Can you provide some simpler examples of the behavior you want?

@abierbaum
Copy link
Author

Let me try simplifying the example above:

const ConfigSchema = z.object({
   name: z.string().default('UserName'),
   image: z.string().nullable().default(null).refine(s => s != null && x.length > 5')
});

type ConfigType = z.infer<typeof ConfigSchema>;

// Build a base set of defaults to pass off to user config module to update/add required configuration
const default_results = ConfigSchema.safeParse({});  // <--- Need something here to just get defaults
const default_cfg = default_results.data;

// At this point the default_cfg would equal:
// {name: 'UserName', image: null }
// 

// ... Call user configuration function to update config
//   - code in here starts with the defaults and then returns a final config that should validate
const user_config = require('./app.config.js')(default_cfg)

// finally: validate the user configuration
const results = ConfigSchema.safeParse(user_config);
const final_config = results.data;

I know I could do something similar by simply not putting defaults in the schema and creating them in a separate object like:

const default_results: ConfigType = {
   name: 'UserName',
   image: null,
}

I am checking to see if there is a way to avoid this and keep all the default value settings as part of the schema so a developer can go to one place to see the structure, validation checks, and default values for the configuration data structure.

Does that make more sense?

So effectively it is something like safeParser() but where it simply returns an object that is filled only with the default values but not validated.

I know this is a bit of an odd use case, but I wanted to check if this is something that may already be supported by going through some hoops.

@colinhacks
Copy link
Owner

colinhacks commented Nov 4, 2020

This is definitely a tricky problem.

I'm confused why this is .nullable but the refinement doesn't allow null values. Why provide a "default" that's invalid?

z.string().nullable().default(null).refine(s => s != null && x.length > 5)

This would work fine:

z.string().nullable().default(null).refine(s => s ? x.length > 5 : true, "error")

Then ConfigSchema.safeParse({}) would work.


Other questions

  • Do you want this work even if some keys don't have a default?
  • Does it need to work on nested keys?

Would it be enough to have an option to disable refinement checks during parsing? This doesn't disable all typechecking, just the .refine checks.

@abierbaum
Copy link
Author

The reason for the null is the tricky part. Basically there are some configuration items that don't have a default so I set them to null or undefined. Then the user in their configuration file has to define them. The "has to" would get enforced by the refine() call and the safeParse() to ensure that we have a valid configuration.

So in pure typescript I would be doing:

interface IConfig {
   name: string | null;
}

const default: IConfig = {
   name: null,
}

// Load user config
const final_cfg = ...

if (final_cfg.name == null) {
   throw error("must provide valid name in config")
}

If a key didn't have a default then I would expect it to default to undefined, but in my case I would give them all defaults.

As far as nested keys, it would definitely need to support nested objects. The configuration object I am using is 2-3 levels deep with configuration settings.

To your question about disabling refinement checks during parsing, that could be interesting. I was actually thinking just a way to extract a copy of anything that has a default. Maybe a helper function that recurses through the schema looking for defaults and adding them into an object that is returned.

@colinhacks
Copy link
Owner

Gotcha, I understand now.

The simple answer is that this isn't possible currently. You can't have a schema that will accept null as a default value and later throw an error when you pass null into it as an input.

You basically need a validator that is able to behave differently depending on the context. In your case you want slightly different behavior depending on whether you're generating the defaults or parsing the final config object. This concept is discussed here: #84

Fortunately JavaScript gives you everything to need to implement this: closures. It's a very advanced pattern but it's really powerful. Here's what I came up with:

const getSettingsSchema = (mode: 'input' | 'output') => {
  const setDefaultByMode = <T extends z.ZodTypeAny>(
    schema: T,
    defaultValue: T['_type'] | null,
  ) => {
    return mode === 'input' ? schema.nullable().default(defaultValue) : schema;
  };

  return z.object({
    name: setDefaultByMode(z.string(), 'DEFAULT'),
    nested: z
      .object({
        inner: setDefaultByMode(z.number(), null),
      })
      .default({}),
  });
};

const inputTest = getSettingsSchema('input');
const outputTest = getSettingsSchema('output');

const defaults = inputTest.parse({});
// => { name: 'DEFAULT', nested: { inner: null } }

console.log(outputTest.parse(defaults));
/*
Error: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "null",
    "path": [
      "nested",
      "inner"
    ],
    "message": "Expected number, received null"
  }
]
*/

You define your schema inside a function that accepts a single string argument (the mode). Then inside the body of the function you can use the mode argument to conditionally return different versions of the schema. This is encapsulated in the setDefaultByMode method which takes a schema X returns a different version depending on the mode. If mode is "input" it makes it nullable and sets a default. If mode is "output" it returns the schema unchanged.

This fixes the problem where you were making all your schemas nullable, then using a custom refinement to prevent nulls later.

@abierbaum
Copy link
Author

@vriad That is brilliant!! Thank you so much for that idea. I think I have what I need to try it out.

I think we can close this out for now and I will work with this pattern.

@abierbaum
Copy link
Author

Ran into one interesting issues. This pattern works to build up the schema, but the type of the schema returned by getSettingSchema includes the nullable. So if I try to infer the strict schema into a typescript type it still has the nulls involved.

const outputTest = getSettingsSchema('output');
type strict_type = z.infer<typeof outputTest>;

// ends up with strict_type equal to
type strict_type = {
    name: string | null;
    nested: {
        inner: number | null;
    };
}

I can probably still make this workable but would be great to find a way to infer the correct typescript type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants