zod icon indicating copy to clipboard operation
zod copied to clipboard

How to transform empty strings into null? `z.emptyStringToNull()`

Open xmlking opened this issue 3 years ago • 30 comments

How to support empty strings for z.string().datetime({ offset: true }).nullish() schema if the string value is empty, I want it converted to null or keep the original value ie., ""

the following code throw error

import { afterAll, beforeAll } from 'vitest';
import { z } from 'zod';


describe('Test zod validations', () => {

	it('should correctly handles a valid ISO date-string', () => {
		const valid_from = '2022-12-14T22:07:10.430805+00:00';
		const valid_to = undefined; <-- this works
                 const valid_to = null; <-- this works
                 const valid_to =""; <-- this is not working


		const schema = z.string().datetime({ offset: true }).nullish();

		expect(schema.parse(valid_from)).toStrictEqual(valid_from);
		expect(schema.parse(valid_to)).toStrictEqual(valid_to);
	});
});

ERROR

ZodError: [
  {
    "code": "invalid_string",
    "validation": "datetime",
    "message": "Invalid datetime",
    "path": []
  }
]


Serialized Error: {
  "addIssue": "Function<>",
  "addIssues": "Function<>",
  "errors": [
    {
      "code": "invalid_string",
      "message": "Invalid datetime",
      "path": [],
      "validation": "datetime",
    },
  ],
  "flatten": "Function<flatten>",
  "formErrors": {
    "fieldErrors": {},
    "formErrors": [
      "Invalid datetime",
    ],
  },
  "format": "Function<format>",
  "isEmpty": false,
  "issues": [
    {
      "code": "invalid_string",
      "message": "Invalid datetime",
      "path": [],
      "validation": "datetime",
    },
  ],
}

xmlking avatar Dec 18 '22 21:12 xmlking

this worked for me

const schema = z.string().datetime({ offset: true }).nullish().or(z.string().max(0));

denvaki avatar Dec 19 '22 10:12 denvaki

thanks @denvaki but I want const valid_to ="" treated same like valid_to = null;

ie., valid_to= ""; schema.parse(valid_to) == null

Reason: I get FormData on the server side, form input field of type datetime-local is sent as empty string when this field is bind to null value in the browser. this is the default behavior for FormData.

when saving to DB, I like Zod transforming it automatically to null as I will be sending null to DB via update SQL.

xmlking avatar Dec 19 '22 19:12 xmlking

Yeah, I have the exact same problem, problably zod can add a option to convert '' to null

You can solve with that:

export const dbOffsetDate = z.preprocess(
  (arg) => (arg === '' ? null : arg),
  z.string().datetime({ offset: true }).nullish(),
);

Then reuse the dbOffsetDate on your app

AndreiLucas123 avatar Dec 19 '22 19:12 AndreiLucas123

Wish we have built-in z.nuller like z.coerce

z.nuller.string().datetime({ offset: true }).nullish()

My reusable solution:

export function emptyToNull(arg: unknown) {
	if (typeof arg !== 'string') {
		return arg;
	}
	if (arg.trim() === '') {
		return null;
	}
	return arg;
}


const valid_to = '';
const emptySchema = z.preprocess(emptyToNull, z.string().datetime({ offset: true }).nullish());
expect(emptySchema.parse(valid_to)).toStrictEqual(null);

another workaround:

const emptySchema =  z.string().datetime({ offset: true }).nullish().catch(null);

xmlking avatar Dec 20 '22 03:12 xmlking

I think the workarounds are sufficient here. I don't think any of the other string validators would accept an empty string as a valid option.

You wouldn't consider an empty string as a valid date

samchungy avatar Dec 21 '22 01:12 samchungy

A z.nuller would convert empty strings to null (if nullable), or to undefined (if not nullable, but optional), and throw Required if not nullable or optional

Or return default if empty string or null

Now zod does not return default if the value is null and this behaviour is probably intentional, but in a new mode like z.nuller, it could treat null and empty strings as Required fields

AndreiLucas123 avatar Dec 21 '22 04:12 AndreiLucas123

z.string().datetime({ offset: true }).nullish() throwing invalid error, when string is empty seams like wrong, especially when I chain with .nullish() I don’t like preprocess wrapper API. Prefer either datatime({empty: null}) option or coerce style nuller

xmlking avatar Dec 21 '22 05:12 xmlking

I think the workarounds are sufficient here. I don't think any of the other string validators would accept an empty string as a valid option.

You wouldn't consider an empty string as a valid date

I completely agree with @samchungy. '' != null therefore I don't think this should be included in Zod.

Try using .transform to achieve your desired outcome.

JacobWeisenburger avatar Dec 25 '22 13:12 JacobWeisenburger

Does this work for you?

const schema = z.string().nullish()
    .transform( value => value === '' ? null : value )
    .pipe( z.string().datetime().nullish() )

console.log( schema.parse( undefined ) ) // undefined
console.log( schema.parse( null ) ) // null
console.log( schema.parse( '' ) ) // null
console.log( schema.parse( '0000-00-00T00:00:00Z' ) ) // 0000-00-00T00:00:00Z

console.log( schema.safeParse( '000-00-00T00:00:00Z' ).success ) // false
console.log( schema.safeParse( 'invalid string' ).success ) // false
console.log( schema.safeParse( 42 ).success ) // false
console.log( schema.safeParse( false ).success ) // false

JacobWeisenburger avatar Dec 26 '22 23:12 JacobWeisenburger

Solutions with transform and preprocess make code less appealing and boilerplate code ( two times nullish() & string() etc.)

I would like something like this:

z.coerce.string({allowEmpty: false}).nullish().parse(''); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse(''); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse('  '); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse("2020-01-01T00:00:00Z"); // pass

xmlking avatar Dec 27 '22 03:12 xmlking

Solutions with transform and preprocess make code less appealing and boilerplate code ( two times nullish() & string() etc.)

I would like something like this:

z.coerce.string({allowEmpty: false}).nullish().parse(''); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse(''); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse('  '); // => null
z.coerce.string({allowEmpty: false}).datetime().nullish().parse("2020-01-01T00:00:00Z"); // pass

What you're arguing for here isn't realistically possible and doesn't make much sense from a design pov.

What you want is for something to both convert an empty string to a null value as well as pass validation. That is

  1. a transformation of empty string to null
  2. Make the validator pass against something which isn't valid.

Think about it without transformations. What you are looking for is for something to match an empty string OR a valid date. An empty string !== a valid date.

The string coercer tries to convert an unknown into a string so that doesn't make sense.

To me preprocess here makes the most sense as you are looking to transform your input before validating.

Or matching against a datetime string union with an empty string and a transform to null for empty strings

If you opened up another issue about wanting an empty string to null coercer that would make more sense

samchungy avatar Dec 27 '22 06:12 samchungy

Thanks for clarifying.

If you opened up another issue about wanting an empty string to null coercer that would make more sense

In my use case, I was transforming String data to Array/Set/Map and I have to check value is empty before converting them. When dealing with the HTML FormData object, the values are always strings and if the user doesn't fill them, they come as empty strings to the server side. on the server side, I wanted to convert them to null as that is what DB/GraphQL APIs expected for nullable columns.

Since I see this common pattern needed for handling FormData , I wanted to have Empty String to null built-in to zod.

https://github.com/xmlking/svelte-starter-kit/blob/main/src/lib/utils/zod.utils.ts#L66


export function ifNonEmptyString(fn: (value: string) => unknown): (value: unknown) => unknown {
	return (value: unknown) => {
		if (typeof value !== 'string') {
			return value;
		}

		if (value === '') {
			// return undefined;
			return null;
		}

		return fn(value);
	};
}

export const stringToArray = ifNonEmptyString((arg) => arg.split(','));
export const stringToMap = ifNonEmptyString((arg) => new Map(Object.entries(JSON.parse(arg))));

is this option make sense?

z.coerce.string().datetime({emptyToNull: true}).nullish().parse('  '); // => null

xmlking avatar Dec 27 '22 06:12 xmlking

I don't think you follow.

An empty string !== datetime so you would never have any option in datetime like that.

Realistically if you wanted something it'd look more like:

z.emptyStringToNull().datetime().nullish()

Obviously emptyStringToNull would be named better

samchungy avatar Dec 27 '22 07:12 samchungy

Agree z.emptyStringToNull() is what I am looking for. It will be useful for many of my use-cases

xmlking avatar Dec 27 '22 19:12 xmlking

With the likes of Remix (and now Next.js) using forms, when submitting inputs which the user has left empty, the sent payload is always an empty string. So all my database columns are being updated to empty strings. I guess that's not so bad. I do manually transform them to null if it's a nullable field and the value is empty but something built in would be handy!

I'm now currently doing something like this:

export const NullableFormString = z.preprocess((v) => v === "" ? null: v, z.string().nullish())

Understand that this is very web/html/form specific, and for general data schemas it probably doesn't make sense, but I do feel a lot of users use Zod to validate and parse web form submissions and this is quite common.

JClackett avatar May 06 '23 13:05 JClackett

just to add more options

function neutralValuesToNull<T extends z.ZodTypeAny>(zodType: T) {
  return z.preprocess(
    (val) => (val === "" || val === undefined ? null : val),
    zodType
  );
}

export const ZodSchema = z.object({
  id: z.coerce.string().cuid(),
  desc: neutralValuesToNull(z.coerce.string().max(1000).nullable()),
  date: neutralValuesToNull(z.coerce.date().nullable()),
  approved: neutralValuesToNull(
    z.coerce.number().int().min(0).nullable()
  ),
})

wellitonscheer avatar Jan 26 '24 12:01 wellitonscheer

I had the same problem with using zod with tRPC and i came up with an idea to parse all the rawInputs in a middleware so when they get to zod they are already transformed into undefined here is how i did it

const middleware = t.middleware(opts => {
  if (opts.rawInput != null && typeof opts.rawInput == 'object' && !Array.isArray(opts.rawInput)) {
    const inputs: Record<any, any> = opts.rawInput
    Object.keys(inputs).forEach(key => {
      if (inputs[key] == null || (typeof inputs[key] == 'string' && inputs[key] == '')) inputs[key] = undefined
      if (Array.isArray(inputs[key]))
        inputs[key] = (inputs[key] as any[]).filter(i => (i == null || (typeof i == 'string' && i == '') ? false : true))
    })
    opts.rawInput = inputs
  }
  return opts.next(opts)
})

rawand-faraidun avatar Feb 16 '24 20:02 rawand-faraidun

So what is better?

export function z_coerciveOptional_PreprocessVersion<T extends z.ZodTypeAny>(t: T) {
  return z.preprocess(
    (val) => {
      const isEmpty = typeof val == "number" && isNaN(val)
        || typeof val == "string" && val === ""
        || val === null
        || Array.isArray(val) && val.length == 0
      return isEmpty ? undefined : val
    }, t.optional()
  )
}

export function z_coerciveOptional_TransformVersion<Z extends z.ZodTypeAny>(z: Z) {
  return z.optional().transform(
    (val: unknown) => {
      const isEmpty = typeof val == "number" && isNaN(val)
        || typeof val == "string" && val === ""
        || val === null
        || Array.isArray(val) && val.length == 0
      return isEmpty ? undefined : val
    })
}

Both work identically AFAICT 🤔

ivan-kleshnin avatar Apr 17 '24 07:04 ivan-kleshnin

There are a lot of ways to solve this, as others have stated. My preferred solution is probably something like this:

z.union([z.literal("").transform(() => null), z.string().datetime()]).nullish();

But ultimately @xmlking is right in that this is way too sophisticated for such a common use case. Form validation is one of the top use cases for Zod, and it needs to be addressed. Though z.emptyStringToNull() isn't a viable API, it's just way too specific.

The best idea I have currently is to add a key to .optional() and .nullable(). You can think of ZodOptional like this: if the input is undefined, then immediately stop parsing and return undefined. The new key would let you expand the set of "trigger values". (

z.string().datetime().optional({
  inputs: [""] // if the input matches anything in this list, the schema returns `undefined`
})

This could optionally be a positional argument (but I think the behavior of this is pretty unclear).

z.string().datetime().optional([ "" ])

The same proposal could apply to .nullable()


Another idea is to add a method .empty() to ZodString which returns a ZodEffects instance.

z.string().empty(null); // => ZodEffects

// identical to
z.string().transform(val => val === "" ? null : val)

colinhacks avatar Apr 19 '24 02:04 colinhacks

Another idea is to add a method .empty() to ZodString which returns a ZodEffects instance.

z.string().empty(null); // => ZodEffects

I low-key love this. So elegant. But the behaviour might be mistaken with a similar api: z.string().nonempty(). Though that API is deprecated so you could possibly remove it and introduce this in 4.0.0? What does z.string().empty() default to? undefined? Does it look more like this schema takes in only an empty string?

Would something like onEmpty() be more obvious that it's a transformation?

z.string().onEmpty(null);

samchungy avatar Apr 19 '24 04:04 samchungy

onEmpty(null) would be a bit restrictive if you wanted to transform to something other than a primitive.

e.g. if you wanted empty strings to be transformed to a new instance of an empty array, it would need to accept a callback:

onEmpty(() => [])

... but at that point, you'd pretty much be back to transform((val) => val === "" ? [] : val) again 😅

shirakaba avatar May 03 '24 02:05 shirakaba

We could accept both kinds of inputs?

samchungy avatar May 03 '24 03:05 samchungy

What if you wanted the default to be a callback, though? 🥲

Think it'd be best to simply accept "a factory for a value" rather than "a factory for a value, or the value itself".

shirakaba avatar May 03 '24 03:05 shirakaba

I'm here because I ran into the same issue (using Zod to validate a date input), it's clearly a pain point that a lot of us feel. And it's definitely widespread enough of a use case that it I think Zod should consider offering an ergonomic way of handling it.

I'm currently using this solution:

export const date = z.preprocess(
  (arg) => (arg === '' ? null : arg),
  z.string().date().nullish(),
)

But it's suboptimal because it gives me an unknown to deal with, the type in general is a mess:

const date: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null | undefined, unknown>

I just want a simple way to use Zod to validate input from a date input. This simple task is surprising convoluted and not straightforward. There's clearly value in Zod supporting this use case in some native/ergonomic way.

Would something like onEmpty() be more obvious that it's a transformation?

Maybe whenEmptyUse() is more clear?

kee-oth avatar Jul 10 '24 03:07 kee-oth