8000 How to transform empty strings into null? `z.emptyStringToNull()` · Issue #1721 · colinhacks/zod · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

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

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

Open
xmlking opened this issue Dec 18, 2022 · 30 comments
Open

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

xmlking opened this issue Dec 18, 2022 · 30 comments
Labels
enhancement New feature or request

Comments

@xmlking
Copy link
xmlking commented Dec 18, 2022

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",
    },
  ],
}
@denvaki
Copy link
denvaki commented Dec 19, 2022

this worked for me

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

@xmlking
Copy link
Author
xmlking commented Dec 19, 2022

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.

@AndreiLucas123
Copy link

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

@xmlking
Copy link
Author
xmlking commented Dec 20, 2022

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);

@samchungy
Copy link
Contributor
samchungy commented Dec 21, 2022

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

@AndreiLucas123
Copy link

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

@xmlking
Copy link
Author
xmlking commented Dec 21, 2022

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

@JacobWeisenburger
Copy link
Contributor

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 JacobWeisenburger added the closeable? This might be ready for closing label Dec 25, 2022
@JacobWeisenburger
Copy link
Contributor

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

@xmlking
Copy link
Author
xmlking commented Dec 27, 2022

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

@samchungy
Copy link
Contributor
samchungy commented Dec 27, 2022

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

@xmlking
Copy link
Author
xmlking commented Dec 27, 2022

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

@samchungy
Copy link
Contributor
samchungy commented Dec 27, 2022

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

@xmlking
Copy link
Author
xmlking commented Dec 27, 2022

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

@JacobWeisenburger JacobWeisenburger removed the closeable? This might be ready for closing label Dec 27, 2022
@JacobWeisenburger JacobWeisenburger changed the title How to support empty strings for z.string().datetime({ offset: true }).nullish() How to transform empty strings into null z.emptyStringToNull() Jan 3, 2023
@JacobWeisenburger JacobWeisenburger changed the title How to transform empty strings into null z.emptyStringToNull() How to transform empty strings into null z.emptyStringToNull()? Jan 3, 2023
@JacobWeisenburger JacobWeisenburger added the enhancement New feature or request label Jan 3, 2023
@JacobWeisenburger JacobWeisenburger changed the title How to transform empty strings into null z.emptyStringToNull()? How to transform empty strings into null? z.emptyStringToNull() Jan 3, 2023
@JClackett
Copy link
JClackett commented May 6, 2023

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.

@wellitonscheer
Copy link

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()
  ),
})

@rawand-faraidun
Copy link

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)
})

@ivan-kleshnin
Copy link
Contributor
ivan-kleshnin commented Apr 17, 2024

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 🤔


Post Update

Found one difference concerning min() handling:

Transform based

let z_optionalString1 = z.string().min(2).max(10).optional().transform(s =>
  s === undefined || s === null || s === "" ? undefined : s
)
  • Can't really use .min() (validation fails before transform is applied)

Preprocess based

let z_optionalString2 = z.preprocess((v) => v === "" ? undefined: v, z.string().min(2).max(10).optional())
  • Can be used with .min() (becomes undefined before validation)

Union + Transform based (proposed by @colinhacks below)

function optionalString(str: z.ZodString) {
  return z.union([z.literal("").transform(() => undefined), str.optional()])
}
let z_optionalString3 = optionalString(z.string().min(2).max(10))
  • Can be used with .min() (becomes undefined before validation)

@colinhacks
Copy link
Owner
colinhacks commented Apr 19, 2024

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)

@samchungy
Copy link
Contributor
samchungy commented Apr 19, 2024

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);

@shirakaba
Copy link

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 😅

@samchungy
Copy link
Contributor

We could accept both kinds of inputs?

@shirakaba
Copy link

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

@kee-oth
Copy link
kee-oth commented Jul 10, 2024

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?

@Armadillidiid
Copy link

Just encountered this issue on a project. Look forward to it getting resolved

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

Thank you for this!

@ivan-kleshnin
Copy link
Contributor
ivan-kleshnin commented Sep 12, 2024

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?

This would be incompatible with min() / max() rules. I've updated my post above to reflect that but, ultimately:

  • you can't min(any_number) after transform()
  • you can't min(any_number_gt_1) before transform() if you want to capture an empty string as undefined / null and validate it ONLY when it's non empty.

So preprocess and union + transform options seem to be more flexible.

@lloydjatkinson
Copy link
lloydjatkinson commented Oct 11, 2024

Also would like to state my support for this. I'm dealing with an API that has a lot of questionable design decisions, one being returning an empty string instead of null or some discriminated union to indicate no value. Having a simple "if empty then use null" feature in Zod would be great for this.

@ayloncarrijo
Copy link

I'm in the same case as @lloydjatkinson. I'm using an API that has a lot of questionable design decisions, so I needed some way to handle these empty strings globally.
I modified Zod's prototype to add a similar functionality to the Jackson's API (from Java), where you can add some custom deserializers for specific types.

Here is how I'm using it:

z.deserializers.add(ZodString, function (input) {
  if (typeof input.data === "string") {
    const isBlank = input.data.trim().length === 0;
    input.data = isBlank ? undefined : input.data;
  }

  return input;
});

And here is the implementation:

import {
  ParseInput,
  z,
  ZodArray,
  ZodBigInt,
  ZodBoolean,
  ZodDate,
  ZodNumber,
  ZodObject,
  ZodString,
  ZodSymbol,
  ZodType,
  ZodTypeDef,
} from "zod";

declare module "zod" {
  export namespace z {
    const deserializers: Deserializers;
  }
}

type Deserializer<
  Output = unknown,
  Def extends ZodTypeDef = ZodTypeDef,
  Input = Output
> = (this: ZodType<Output, Def, Input>, input: z.ParseInput) => z.ParseInput;

// Typesafe Heterogenous Containers Pattern
class Deserializers {
  private readonly deserializers = new Map<
    object,
    Array<(...args: Array<never>) => unknown>
  >();

  public add<
    Output = unknown,
    Def extends ZodTypeDef = ZodTypeDef,
    Input = Output
  >(
    type: typeof ZodType<Output, Def, Input>,
    deserializer: Deserializer<Output, Def, Input>
  ): void {
    this.deserializers.set(type, [...this.get(type), deserializer]);
  }

  public get<
    Output = unknown,
    Def extends ZodTypeDef = ZodTypeDef,
    Input = Output
  >(
    type: typeof ZodType<Output, Def, Input>
  ): Array<Deserializer<Output, Def, Input>> {
    return (this.deserializers.get(type) ?? []) as Array<
      Deserializer<Output, Def, Input>
    >;
  }
}

Reflect.set(z, "deserializers", new Deserializers());

function augment<
  Output = unknown,
  Def extends ZodTypeDef = ZodTypeDef,
  Input = Output
>(type: typeof ZodType<Output, Def, Input>) {
  type.prototype._parse = new Proxy(type.prototype._parse, {
    apply(
      target,
      thisArg: ZodType<Output, Def, Input>,
      argArray: [ParseInput]
    ) {
      const [input] = argArray;

      const deserializers = z.deserializers.get(type);

      const deserializedInput = deserializers.reduce(
        (accumulator, deserializer) => deserializer.call(thisArg, accumulator),
        input
      );

      return target.call(thisArg, deserializedInput);
    },
  });
}

augment(ZodObject);
augment(ZodArray);
augment(ZodString);
augment(ZodNumber);
augment(ZodBigInt);
augment(ZodBoolean);
augment(ZodDate);
augment(ZodSymbol);

@stephan-noel
Copy link

Now that zod 4 beta is out, will there be some "blessed" path for this?

Though I do like z.string().empty(null);, having to define that on every single property of a large schema is somewhat redundant. I think what I would like is to perform property-level transforms on an entire schema.

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

No branches or pull requests

0