zod icon indicating copy to clipboard operation
zod copied to clipboard

Support for date, time and date-time format strings

Open maneetgoyal opened this issue 5 years ago • 18 comments

Currently, Zod is lacking support for validating date, time and date-time stamps. As a result, users may need to implement custom validation via .refine. But the use case for date/time/date-time is very common, so if Zod ships it out-of-the-box like it does for uuid, email, etc, it would be helpful.

  • Stemmed from https://github.com/vriad/zod/issues/30#issuecomment-679383286
  • AJV's in-built regexes are here for reference.

maneetgoyal avatar Aug 25 '20 05:08 maneetgoyal

Agreed! Although date and time validation is a tricky issue as different developers expect different data (ISO? YYYY-MM-DD? 12h Time? With milliseconds? Timezone notation?).

lazharichir avatar Aug 26 '20 09:08 lazharichir

  • Now that zod.string().regex(...) is available since v1.11, it has become easier to implement custom date/time/date-time patterns.
  • Some out-of-the-box support for date/time/etc. can still be useful though. Using zod.string().regex(), users can modify the default behavior if it doesn't meet their expectations.
  • The default behavior I was proposing is from AJV which is a hugely popular data validation library. I am assuming they'd have brought in many improvements to their regex over time making it increasingly robust. But yes, there can be some unhandled cases as suggested above in https://github.com/vriad/zod/issues/126#issuecomment-680782741 and in https://github.com/vriad/zod/issues/30#issuecomment-673133452.

maneetgoyal avatar Sep 01 '20 05:09 maneetgoyal

If it helps, here's how djv is handling date-time strings. Their approach looks a bit similar to the idea that emerged in https://github.com/vriad/zod/issues/120 (i.e. using instantiation as a way to validate).

However, this approach may come with its own drawbacks.

maneetgoyal avatar Sep 01 '20 06:09 maneetgoyal

I use the .refine() method for these cases, works like a charm and covers all of the special cases I need it to cover without introducing all of the datetime-standards related clutter

grreeenn avatar Sep 20 '20 02:09 grreeenn

Hey @vriad are you still open to having AJV date/time/date-time regexes as built-ins? If so I'll open a PR sometime this week.

marlonbernardes avatar Oct 13 '20 11:10 marlonbernardes

RFC for datetime methods

The hard problem here is naming the methods such that the validation behavior is clear to everyone. I'm opposed to adding a method called .datetime() since different people will have different ideas of what should be valid. I don't like AJV's datetime regex since it allows timezone offsets, which should almost never ever be used imo.

Here is a set of method names I'd be happy with:

  • z.string().date()2020-10-14
  • z.string().time()T18:45:12.123 (T is optional)
  • z.string().utc()2020-10-14T17:42:29Z (no offsets allowed)
  • z.string().iso8601()2020-10-14T17:42:29+00:00 (offset allowed)

The longer method name iso8601 makes the validation rule explicit. The documentation will clearly indicate that using UTC date strings is a best practice and will encourage usage of .utc() over .iso8601().

Milliseconds will be supported but not required in all cases.

colinhacks avatar Oct 14 '20 18:10 colinhacks

Could something be achieved in user land via something akin to yup's addMethod?

It would be great to add in custom methods of built-in types to allow extensions.

eberens-nydig avatar Nov 11 '20 17:11 eberens-nydig

I don't know how to implement addMethod such that TypeScript is aware of the new method. Since TypeScript isn't aware of the method there would (rightfully) be a SyntaxError if you ever tried to use it. This is an area where Yup is capable of more flexible behavior because it has less stringent standards for static typing.

colinhacks avatar Nov 11 '20 17:11 colinhacks

I stumbled across this comment where you recommend a subclass. Given that could we achieve the same as above like so?

import { parse, parseISO } from 'date-fns';
import z from 'zod';

function dateFromString(value: string): Date {
  return parse(value, 'yyyy-MM-dd', new Date());
}

function dateTimeFromString(value: string): Date {
  return parseISO(value);
}

export class MyZodString extends z.ZodString {
  static create = (): ZodString =>
    new ZodString({
      t: ZodTypes.string,
      validation: {}
    });
  date = () => this.transform(z.date(), dateFromString);
  iso8601 = () => this.transform(z.date(), dateTimeFromString);
}

const stringType = ZodString.create;

export { stringType as string };

then consume...

import { string } from '.';
import z from 'zod';

export const schema = z.object({
  data: string().iso8601()
});

eberens-nydig avatar Nov 11 '20 17:11 eberens-nydig

The general approach works but there are some problems with this implementation. You'd have to return an instance of MyStringClass from the static create factory. Here's a working implementation:

import * as z from '.';
import { parse, parseISO } from 'date-fns';

function dateFromString(value: string): Date {
  return parse(value, 'yyyy-MM-dd', new Date());
}

function dateTimeFromString(value: string): Date {
  return parseISO(value);
}

export class MyZodString extends z.ZodString {
  static create = () =>
    new MyZodString({
      t: z.ZodTypes.string,
      validation: {},
    });
  date = () => this.transform(z.date(), dateFromString);
  iso8601 = () => this.transform(z.date(), dateTimeFromString);
}

const stringType = MyZodString.create;
export { stringType as string };

colinhacks avatar Nov 11 '20 19:11 colinhacks

Yes, thanks for the reply. Looks like I have some copypasta issues and didn't update the names. Thanks for confirming!

eberens-nydig avatar Nov 11 '20 19:11 eberens-nydig

@colinhacks what about using the built-in new Date(s), combined with an isNaN check, like io-ts-types does? That way if people want something other than the no-dependencies, vanilla JS solution, they can implement it themselves pretty easily with something like the above. The vast majority of people who just want something like this will be happy:

const User = z.object({
  name: z.string(),
  birthDate: z.date.fromString(),
})

User.parse({ name: 'Alice', birthDate: '1980-01-01' })
// -> ok, returns { name: 'Alice', birthDate: [[Date object]] }

User.parse({ name: 'Bob', birthDate: 'hello' })
// -> Error `'hello' could not be parsed into a valid Date`

User.parse({ name: 'Bob', birthDate: 123 })
// -> Error `123 is not a string`

mmkal avatar Jan 28 '21 20:01 mmkal

This should be implemented!

Sytten avatar Sep 20 '21 22:09 Sytten

Any updates on this?

devinhalladay avatar Oct 20 '21 15:10 devinhalladay

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Mar 02 '22 03:03 stale[bot]

It'd be great to have this as proposed in the RFC. Or if we could just easily extend it with isDate, parse from date-fns or similar libraries.

fev4 avatar May 16 '22 02:05 fev4

What if this were implemented

RFC for datetime methods

The hard problem here is naming the methods such that the validation behavior is clear to everyone. I'm opposed to adding a method called .datetime() since different people will have different ideas of what should be valid. I don't like AJV's datetime regex since it allows timezone offsets, which should almost never ever be used imo.

Here is a set of method names I'd be happy with:

  • z.string().date()2020-10-14
  • z.string().time()T18:45:12.123 (T is optional)
  • z.string().utc()2020-10-14T17:42:29Z (no offsets allowed)
  • z.string().iso8601()2020-10-14T17:42:29+00:00 (offset allowed)

The longer method name iso8601 makes the validation rule explicit. The documentation will clearly indicate that using UTC date strings is a best practice and will encourage usage of .utc() over .iso8601().

Milliseconds will be supported but not required in all cases.

I 100% agree with implementing validators that enforce best practices (i.e. iso8601 strings) for Date objects, but perhaps a more useful feature would be validators that conform to the TC39 proposal for the Temporal global.

I'd certainly feel more comfortable using the polyfill if zod provided a set of validators for the proposal!

helmturner avatar Jul 09 '22 20:07 helmturner

RFC for datetime methods

The hard problem here is naming the methods such that the validation behavior is clear to everyone. I'm opposed to adding a method called .datetime() since different people will have different ideas of what should be valid. I don't like AJV's datetime regex since it allows timezone offsets, which should almost never ever be used imo.

Here is a set of method names I'd be happy with:

  • z.string().date()2020-10-14
  • z.string().time()T18:45:12.123 (T is optional)
  • z.string().utc()2020-10-14T17:42:29Z (no offsets allowed)
  • z.string().iso8601()2020-10-14T17:42:29+00:00 (offset allowed)

The longer method name iso8601 makes the validation rule explicit. The documentation will clearly indicate that using UTC date strings is a best practice and will encourage usage of .utc() over .iso8601().

Milliseconds will be supported but not required in all cases.

:wave: I know this has been a long time since this has been discussed. I'm fairly happy to implement some of this but I think Milliseconds will be supported but not required in all cases. could be confusing? I feel like you would want users to supply either one or the other and not both for consistency sake? For that reason I often use the simple Date.toISOString() function to generate timestamps.

However, in terms of implementation if we wanted to enable both we could go with something like

z.string().utc(); // accept both milliseconds and no milliseconds
z.string().utc({ milliseconds: false }); // accept only no milliseconds
z.string().utc({ milliseconds: true }); // accept only milliseconds

samchungy avatar Oct 13 '22 11:10 samchungy

Support for a configurable z.string().datetime() has landed in Zod 3.20. https://github.com/colinhacks/zod/releases/tag/v3.20

z.string().datetime()

A new method has been added to ZodString to validate ISO datetime strings. Thanks @samchungy!

z.string().datetime();

This method defaults to only allowing UTC datetimes (the ones that end in "Z"). No timezone offsets are allowed; arbitrary sub-second precision is supported.

const dt = z.string().datetime();
dt.parse("2020-01-01T00:00:00Z"); // 🟢
dt.parse("2020-01-01T00:00:00.123Z"); // 🟢
dt.parse("2020-01-01T00:00:00.123456Z"); // 🟢 (arbitrary precision)
dt.parse("2020-01-01T00:00:00+02:00"); // 🔴 (no offsets allowed)

Offsets can be supported with the offset parameter.

const a = z.string().datetime({ offset: true });
a.parse("2020-01-01T00:00:00+02:00"); // 🟢 offset allowed

You can additionally constrain the allowable precision. This specifies the number of digits that should follow the decimal point.

const b = z.string().datetime({ precision: 3 })
b.parse("2020-01-01T00:00:00.123Z"); // 🟢 precision of 3 decimal points
b.parse("2020-01-01T00:00:00Z"); // 🔴 invalid precision

There is still no .date() or .time() method, mostly because those use cases can be trivially implemented with .regex(). They may get added down the road, but I'm going to call this issue resolved.

colinhacks avatar Dec 13 '22 06:12 colinhacks

@colinhacks It looks like datetime accepts the offset as hh:mm only, but according to this https://en.wikipedia.org/wiki/UTC_offset hhmm should be accepted as well. Some frameworks generate it as hhmm by default and that should be an acceptable value.

is generally shown in the format ±[hh]:[mm], ±[hh][mm], or ±[hh]. So if the time being described is two hours ahead of UTC (such as in Kigali, Rwanda [approx. 30° E]), the UTC offset would be "+02:00", "+0200", or simply "+02".

iSeiryu avatar Feb 06 '23 16:02 iSeiryu

@colinhacks It looks like datetime accepts the offset as hh:mm only, but according to this https://en.wikipedia.org/wiki/UTC_offset hhmm should be accepted as well. Some frameworks generate it as hhmm by default and that should be an acceptable value.

is generally shown in the format ±[hh]:[mm], ±[hh][mm], or ±[hh]. So if the time being described is two hours ahead of UTC (such as in Kigali, Rwanda [approx. 30° E]), the UTC offset would be "+02:00", "+0200", or simply "+02".

I can speak to this but the initial regex was based on a StackOverFlow post which referenced the W3 spec for date time. It's also the default format for new Date().toISOString().

samchungy avatar Feb 07 '23 00:02 samchungy

ISO-8601 is actually hhmm: https://www.utctime.net/

image

iSeiryu avatar Feb 08 '23 16:02 iSeiryu

This method defaults to only allowing UTC datetimes (the ones that end in "Z").

Does it mean that there are non-default options (for the ones that doesn't end in "Z")? How to enable them?

asnov avatar Feb 23 '23 14:02 asnov

There is still no .date() or .time() method, mostly because those use cases can be trivially implemented with .regex().

@colinhacks Can they though? The trivial implementation is probably along the lines of [0-9]{4}-[0-9]{2}-[0-9]{2} but this would also accept 2023-13-45 which is not a valid date. Similarly for time something like [0-9]{2}:[0-9]{2}:[0-9]{2} would accept times like 45:98 which are not valid.

shoooe avatar Apr 01 '23 10:04 shoooe

There is still no .date() or .time() method, mostly because those use cases can be trivially implemented with .regex().

@colinhacks Can they though? The trivial implementation is probably along the lines of [0-9]{4}-[0-9]{2}-[0-9]{2} but this would also accept 2023-13-45 which is not a valid date. Similarly for time something like [0-9]{2}:[0-9]{2}:[0-9]{2} would accept times like 45:98 which are not valid.

Feel like you cut the quote off short there. He does mention that it can be add down the road. Feel free to contribute if you'd like. You can probably use the existing datetime regex to figure something out though I don't think that's perfect either.

This method defaults to only allowing UTC datetimes (the ones that end in "Z").

Does it mean that there are non-default options (for the ones that doesn't end in "Z")? How to enable them?

It's literally in the section below where you just read?

samchungy avatar Apr 02 '23 12:04 samchungy

z.string().date() I would want to have this in case I just want user to pass the date. I know it can be done via RegEx but it's not performant.

ShivamJoker avatar Oct 07 '23 10:10 ShivamJoker

This method defaults to only allowing UTC datetimes (the ones that end in "Z").

Does it mean that there are non-default options (for the ones that doesn't end in "Z")? How to enable them?

@asnov There is an open issue for supporting this #2385

And I've just opened a PR to support non-UTC time here #2913

0xturner avatar Oct 28 '23 04:10 0xturner

Having z.string().date() is necessary when using zod with other packages that generate the Open API spec, else the spec should say that the field is a string without format, when it should have the format $date

mbsanchez01 avatar Dec 07 '23 09:12 mbsanchez01

@mbsanchez01 Similar concerns -- I'm using https://github.com/astahmer/typed-openapi for Zod object generation based on openapi spec. Is there a tool you're using to properly generate Zod date objects from openapi? Zod generator recognizes them as just strings.

Have an open issue here for generation of format: date Zod string objects: https://github.com/astahmer/typed-openapi/issues/24#issue-2064548824

tonydattolo avatar Jan 03 '24 19:01 tonydattolo

@mbsanchez01 Similar concerns -- I'm using https://github.com/astahmer/typed-openapi for Zod object generation based on openapi spec. Is there a tool you're using to properly generate Zod date objects from openapi? Zod generator recognizes them as just strings.

Hey @tonydattolo I'm using nestjs-zod which extends zod with a new method dateString

mbsanchez01 avatar Jan 04 '24 08:01 mbsanchez01