Repository: jquense/yup
Pull request #2163 (2024-01-11 12:59:11)

fix(readme): some typos and update CustomizingErrors doc
Fixed some typos in README + updated the CustomizingErrors documentation based on current code

<img width="560" alt="截屏2024-01-12 09 13 38" src="https://github.com/jquense/yup/assets/39440537/e899fb17-222d-41c4-9937-9f2035e5904d">



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01724de..c37fb13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+# [1.4.0](https://github.com/jquense/yup/compare/v1.3.3...v1.4.0) (2024-03-06)
+
+
+### Bug Fixes
+
+* add optional message to nonNullable schema methods ([#2119](https://github.com/jquense/yup/issues/2119)) ([9e1df49](https://github.com/jquense/yup/commit/9e1df4938c1964a21e6ece0c458bb96dc5aff108))
+
+
+### Features
+
+* **string:** Create .datetime() ([#2087](https://github.com/jquense/yup/issues/2087)) ([2a9e060](https://github.com/jquense/yup/commit/2a9e060594423018f517419ef5d2f10e417c9fbd))
+
+
+
+
+
 ## [1.3.3](https://github.com/jquense/yup/compare/v1.3.2...v1.3.3) (2023-12-14)
 
 
diff --git a/README.md b/README.md
index 2bbb32a..645a8dd 100644
--- a/README.md
+++ b/README.md
@@ -111,8 +111,8 @@ const parsedUser = await userSchema.validate(
     - [`Schema.withMutation(builder: (current: Schema) => void): void`](#schemawithmutationbuilder-current-schema--void-void)
     - [`Schema.default(value: any): Schema`](#schemadefaultvalue-any-schema)
     - [`Schema.getDefault(options?: object): Any`](#schemagetdefaultoptions-object-any)
-    - [`Schema.nullable(message?: string | function): Schema`](#schemanullable-schema)
-    - [`Schema.nonNullable(message?: string | function): Schema`](#schemanonnullable-schema)
+    - [`Schema.nullable(message?: string | function): Schema`](#schemanullablemessage-string--function-schema)
+    - [`Schema.nonNullable(message?: string | function): Schema`](#schemanonnullablemessage-string--function-schema)
     - [`Schema.defined(): Schema`](#schemadefined-schema)
     - [`Schema.optional(): Schema`](#schemaoptional-schema)
     - [`Schema.required(message?: string | function): Schema`](#schemarequiredmessage-string--function-schema)
@@ -135,6 +135,8 @@ const parsedUser = await userSchema.validate(
     - [`string.email(message?: string | function): Schema`](#stringemailmessage-string--function-schema)
     - [`string.url(message?: string | function): Schema`](#stringurlmessage-string--function-schema)
     - [`string.uuid(message?: string | function): Schema`](#stringuuidmessage-string--function-schema)
+    - [`string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`](#stringdatetimeoptions-message-string--function-allowoffset-boolean-precision-number)
+    - [`string.datetime(message?: string | function)`](#stringdatetimemessage-string--function)
     - [`string.ensure(): Schema`](#stringensure-schema)
     - [`string.trim(message?: string | function): Schema`](#stringtrimmessage-string--function-schema)
     - [`string.lowercase(message?: string | function): Schema`](#stringlowercasemessage-string--function-schema)
@@ -257,7 +259,7 @@ Error messages can also be constructed on the fly to customize how the schema fa
 
 ```ts
 const order = object({
-  no: number().required().
+  no: number().required(),
   sku: string().test({
     name: 'is-sku',
     skipAbsent: true,
@@ -649,8 +651,8 @@ declare module 'yup' {
   // Define your desired `SchemaMetadata` interface by merging the
   // `CustomSchemaMetadata` interface.
   export interface CustomSchemaMetadata {
-    placeholderText?: string
-    tooltipText?: string
+    placeholderText?: string;
+    tooltipText?: string;
     // …
   }
 }
@@ -1364,6 +1366,19 @@ Validates the value as a valid URL via a regex.
 
 Validates the value as a valid UUID via a regex.
 
+#### `string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`
+
+Validates the value as an ISO datetime via a regex. Defaults to UTC validation; timezone offsets are not permitted (see `options.allowOffset`).
+
+Unlike `.date()`, `datetime` will not convert the string to a `Date` object. `datetime` also provides greater customization over the required format of the datetime string than `date` does.
+
+`options.allowOffset`: Allow a time zone offset. False requires UTC 'Z' timezone. _(default: false)_
+`options.precision`: Require a certain sub-second precision on the date. _(default: null -- any (or no) sub-second precision)_
+
+#### `string.datetime(message?: string | function)`
+
+An alternate signature for `string.datetime` that can be used when you don't need to pass options other than `message`.
+
 #### `string.ensure(): Schema`
 
 Transforms `undefined` and `null` values to an empty string along with
@@ -1464,6 +1479,8 @@ await schema.isValid(new Date()); // => true
 The default `cast` logic of `date` is pass the value to the `Date` constructor, failing that, it will attempt
 to parse the date as an ISO date string.
 
+> If you would like ISO strings to not be cast to a `Date` object, use `.datetime()` instead.
+
 Failed casts return an invalid Date.
 
 #### `date.min(limit: Date | string | Ref, message?: string | function): Schema`
diff --git a/docs/extending.md b/docs/extending.md
index 850594a..ef0ed1a 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -47,8 +47,8 @@ Note that `addMethod` isn't magic, it mutates the prototype of the passed in sch
 
 ## Creating new Schema types
 
-If you're use case calls for creating an entirely new type. inheriting from
-and existing schema class may be best: Generally you should not inheriting from
+If you're using case calls for creating an entirely new type, inheriting from
+an existing schema class may be best: Generally you should not be inheriting from
 the abstract `Schema` unless you know what you are doing. The other types are fair game though.
 
 You should keep in mind some basic guidelines when extending schemas:
@@ -59,7 +59,7 @@ You should keep in mind some basic guidelines when extending schemas:
 - transforms should never mutate the `value` passed in, and should return an invalid object when one exists
   (`NaN`, `InvalidDate`, etc) instead of `null` for bad values.
 
-- by the time validations run the `value` is guaranteed to be the correct type, however it still may
+- by the time validations run, the `value` is guaranteed to be the correct type, however it still may
   be `null` or `undefined`
 
 ```js
diff --git a/package.json b/package.json
index dd1d8ea..57ad989 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "yup",
-  "version": "1.3.3",
+  "version": "1.4.0",
   "description": "Dead simple Object schema validation",
   "main": "lib/index.js",
   "module": "lib/index.esm.js",
diff --git a/src/ValidationError.ts b/src/ValidationError.ts
index 9fce36f..20dee52 100644
--- a/src/ValidationError.ts
+++ b/src/ValidationError.ts
@@ -5,15 +5,59 @@ let strReg = /\$\{\s*(\w+)\s*\}/g;
 
 type Params = Record<string, unknown>;
 
-export default class ValidationError extends Error {
+class ValidationErrorNoStack implements ValidationError {
+  name: string;
+  message: string;
+
   value: any;
   path?: string;
   type?: string;
+  params?: Params;
+
   errors: string[];
+  inner: ValidationError[];
+
+  constructor(
+    errorOrErrors: string | ValidationError | readonly ValidationError[],
+    value?: any,
+    field?: string,
+    type?: string,
+  ) {
+    this.name = 'ValidationError';
+    this.value = value;
+    this.path = field;
+    this.type = type;
+
+    this.errors = [];
+    this.inner = [];
+
+    toArray(errorOrErrors).forEach((err) => {
+      if (ValidationError.isError(err)) {
+        this.errors.push(...err.errors);
+        const innerErrors = err.inner.length ? err.inner : [err];
+        this.inner.push(...innerErrors);
+      } else {
+        this.errors.push(err);
+      }
+    });
+
+    this.message =
+      this.errors.length > 1
+        ? `${this.errors.length} errors occurred`
+        : this.errors[0];
+  }
 
+  [Symbol.toStringTag] = 'Error';
+}
+
+export default class ValidationError extends Error {
+  value: any;
+  path?: string;
+  type?: string;
   params?: Params;
 
-  inner: ValidationError[];
+  errors: string[] = [];
+  inner: ValidationError[] = [];
 
   static formatError(
     message: string | ((params: Params) => string) | unknown,
@@ -40,33 +84,38 @@ export default class ValidationError extends Error {
     type?: string,
     disableStack?: boolean,
   ) {
-    super();
-
-    this.name = 'ValidationError';
-    this.value = value;
-    this.path = field;
-    this.type = type;
+    const errorNoStack = new ValidationErrorNoStack(
+      errorOrErrors,
+      value,
+      field,
+      type,
+    );
 
-    this.errors = [];
-    this.inner = [];
+    if (disableStack) {
+      return errorNoStack;
+    }
 
-    toArray(errorOrErrors).forEach((err) => {
-      if (ValidationError.isError(err)) {
-        this.errors.push(...err.errors);
-        const innerErrors = err.inner.length ? err.inner : [err];
-        this.inner.push(...innerErrors);
-      } else {
-        this.errors.push(err);
-      }
-    });
+    super();
 
-    this.message =
-      this.errors.length > 1
-        ? `${this.errors.length} errors occurred`
-        : this.errors[0];
+    this.name = errorNoStack.name;
+    this.message = errorNoStack.message;
+    this.type = errorNoStack.type;
+    this.value = errorNoStack.value;
+    this.path = errorNoStack.path;
+    this.errors = errorNoStack.errors;
+    this.inner = errorNoStack.inner;
 
-    if (!disableStack && Error.captureStackTrace)
+    if (Error.captureStackTrace) {
       Error.captureStackTrace(this, ValidationError);
+    }
+  }
+
+  static [Symbol.hasInstance](inst: any) {
+    return (
+      ValidationErrorNoStack[Symbol.hasInstance](inst) ||
+      super[Symbol.hasInstance](inst)
+    );
   }
+
   [Symbol.toStringTag] = 'Error';
 }
diff --git a/src/locale.ts b/src/locale.ts
index d097c83..6b70f68 100644
--- a/src/locale.ts
+++ b/src/locale.ts
@@ -20,6 +20,9 @@ export interface StringLocale {
   email?: Message<{ regex: RegExp }>;
   url?: Message<{ regex: RegExp }>;
   uuid?: Message<{ regex: RegExp }>;
+  datetime?: Message;
+  datetime_offset?: Message;
+  datetime_precision?: Message<{ precision: number }>;
   trim?: Message;
   lowercase?: Message;
   uppercase?: Message;
@@ -100,6 +103,11 @@ export let string: Required<StringLocale> = {
   email: '${path} must be a valid email',
   url: '${path} must be a valid URL',
   uuid: '${path} must be a valid UUID',
+  datetime: '${path} must be a valid ISO date-time',
+  datetime_precision:
+    '${path} must be a valid ISO date-time with a sub-second precision of exactly ${precision} digits',
+  datetime_offset:
+    '${path} must be a valid ISO date-time with UTC "Z" timezone',
   trim: '${path} must be a trimmed string',
   lowercase: '${path} must be a lowercase string',
   uppercase: '${path} must be a upper case string',
diff --git a/src/string.ts b/src/string.ts
index bfcade2..c193d00 100644
--- a/src/string.ts
+++ b/src/string.ts
@@ -14,6 +14,7 @@ import type {
   Optionals,
 } from './util/types';
 import Schema from './schema';
+import { parseDateStruct } from './util/parseIsoDate';
 
 // Taken from HTML spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
 let rEmail =
@@ -28,6 +29,13 @@ let rUrl =
 let rUUID =
   /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
 
+let yearMonthDay = '^\\d{4}-\\d{2}-\\d{2}';
+let hourMinuteSecond = '\\d{2}:\\d{2}:\\d{2}';
+let zOrOffset = '(([+-]\\d{2}(:?\\d{2})?)|Z)';
+let rIsoDateTime = new RegExp(
+  `${yearMonthDay}T${hourMinuteSecond}(\\.\\d+)?${zOrOffset}$`,
+);
+
 let isTrimmed = (value: Maybe<string>) =>
   isAbsent(value) || value === value.trim();
 
@@ -37,6 +45,14 @@ export type MatchOptions = {
   name?: string;
 };
 
+export type DateTimeOptions = {
+  message: Message<{ allowOffset?: boolean; precision?: number }>;
+  /** Allow a time zone offset. False requires UTC 'Z' timezone. (default: false) */
+  allowOffset?: boolean;
+  /** Require a certain sub-second precision on the date. (default: undefined -- any or no sub-second precision) */
+  precision?: number;
+};
+
 let objStringTag = {}.toString();
 
 function create(): StringSchema;
@@ -200,6 +216,54 @@ export default class StringSchema<
     });
   }
 
+  datetime(options?: DateTimeOptions | DateTimeOptions['message']) {
+    let message: DateTimeOptions['message'] = '';
+    let allowOffset: DateTimeOptions['allowOffset'];
+    let precision: DateTimeOptions['precision'];
+
+    if (options) {
+      if (typeof options === 'object') {
+        ({
+          message = '',
+          allowOffset = false,
+          precision = undefined,
+        } = options as DateTimeOptions);
+      } else {
+        message = options;
+      }
+    }
+
+    return this.matches(rIsoDateTime, {
+      name: 'datetime',
+      message: message || locale.datetime,
+      excludeEmptyString: true,
+    })
+      .test({
+        name: 'datetime_offset',
+        message: message || locale.datetime_offset,
+        params: { allowOffset },
+        skipAbsent: true,
+        test: (value: Maybe<string>) => {
+          if (!value || allowOffset) return true;
+          const struct = parseDateStruct(value);
+          if (!struct) return false;
+          return !!struct.z;
+        },
+      })
+      .test({
+        name: 'datetime_precision',
+        message: message || locale.datetime_precision,
+        params: { precision },
+        skipAbsent: true,
+        test: (value: Maybe<string>) => {
+          if (!value || precision == undefined) return true;
+          const struct = parseDateStruct(value);
+          if (!struct) return false;
+          return struct.precision === precision;
+        },
+      });
+  }
+
   //-- transforms --
   ensure(): StringSchema<NonNullable<TType>> {
     return this.default('' as Defined<TType>).transform((val) =>
diff --git a/src/util/createValidation.ts b/src/util/createValidation.ts
index 036fbfd..cb55c8f 100644
--- a/src/util/createValidation.ts
+++ b/src/util/createValidation.ts
@@ -98,6 +98,7 @@ export default function createValidation(config: {
         label: schema.spec.label,
         path: overrides.path || path,
         spec: schema.spec,
+        disableStackTrace: overrides.disableStackTrace || disableStackTrace,
         ...params,
         ...overrides.params,
       };
@@ -111,7 +112,7 @@ export default function createValidation(config: {
         value,
         nextParams.path,
         overrides.type || name,
-        overrides.disableStackTrace ?? disableStackTrace,
+        nextParams.disableStackTrace,
       );
       error.params = nextParams;
       return error;
@@ -158,10 +159,7 @@ export default function createValidation(config: {
               `This test will finish after the validate call has returned`,
           );
         }
-        return Promise.resolve(result).then(
-          handleResult,
-          handleError,
-        );
+        return Promise.resolve(result).then(handleResult, handleError);
       }
     } catch (err: any) {
       handleError(err);
diff --git a/src/util/parseIsoDate.ts b/src/util/parseIsoDate.ts
index d53c5cc..d5f7975 100644
--- a/src/util/parseIsoDate.ts
+++ b/src/util/parseIsoDate.ts
@@ -10,32 +10,9 @@
 //                1 YYYY                2 MM        3 DD              4 HH     5 mm        6 ss           7 msec         8 Z 9 ±   10 tzHH    11 tzmm
 const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/;
 
-function toNumber(str: string, defaultValue = 0) {
-  return Number(str) || defaultValue;
-}
-
 export function parseIsoDate(date: string): number {
-  const regexResult = isoReg.exec(date);
-  if (!regexResult) return Date.parse ? Date.parse(date) : Number.NaN;
-
-  // use of toNumber() avoids NaN timestamps caused by “undefined”
-  // values being passed to Date constructor
-  const struct = {
-    year: toNumber(regexResult[1]),
-    month: toNumber(regexResult[2], 1) - 1,
-    day: toNumber(regexResult[3], 1),
-    hour: toNumber(regexResult[4]),
-    minute: toNumber(regexResult[5]),
-    second: toNumber(regexResult[6]),
-    millisecond: regexResult[7]
-      ? // allow arbitrary sub-second precision beyond milliseconds
-        toNumber(regexResult[7].substring(0, 3))
-      : 0,
-    z: regexResult[8] || undefined,
-    plusMinus: regexResult[9] || undefined,
-    hourOffset: toNumber(regexResult[10]),
-    minuteOffset: toNumber(regexResult[11]),
-  };
+  const struct = parseDateStruct(date);
+  if (!struct) return Date.parse ? Date.parse(date) : Number.NaN;
 
   // timestamps without timezone identifiers should be considered local time
   if (struct.z === undefined && struct.plusMinus === undefined) {
@@ -66,3 +43,32 @@ export function parseIsoDate(date: string): number {
     struct.millisecond,
   );
 }
+
+export function parseDateStruct(date: string) {
+  const regexResult = isoReg.exec(date);
+  if (!regexResult) return null;
+
+  // use of toNumber() avoids NaN timestamps caused by “undefined”
+  // values being passed to Date constructor
+  return {
+    year: toNumber(regexResult[1]),
+    month: toNumber(regexResult[2], 1) - 1,
+    day: toNumber(regexResult[3], 1),
+    hour: toNumber(regexResult[4]),
+    minute: toNumber(regexResult[5]),
+    second: toNumber(regexResult[6]),
+    millisecond: regexResult[7]
+      ? // allow arbitrary sub-second precision beyond milliseconds
+        toNumber(regexResult[7].substring(0, 3))
+      : 0,
+    precision: regexResult[7]?.length ?? undefined,
+    z: regexResult[8] || undefined,
+    plusMinus: regexResult[9] || undefined,
+    hourOffset: toNumber(regexResult[10]),
+    minuteOffset: toNumber(regexResult[11]),
+  };
+}
+
+function toNumber(str: string, defaultValue = 0) {
+  return Number(str) || defaultValue;
+}


Test patch:
diff --git a/test/ValidationError.ts b/test/ValidationError.ts
index 6084b50..8d70200 100644
--- a/test/ValidationError.ts
+++ b/test/ValidationError.ts
@@ -49,4 +49,11 @@ describe('ValidationError', () => {
       expect(str).toContain('0');
     });
   });
+
+  it('should disable stacks', () => {
+    const disabled = new ValidationError('error', 1, 'field', 'type', true);
+
+    expect(disabled.constructor.name).toEqual('ValidationErrorNoStack');
+    expect(disabled).toBeInstanceOf(ValidationError);
+  });
 });
diff --git a/test/array.ts b/test/array.ts
index b01b5b1..0771dee 100644
--- a/test/array.ts
+++ b/test/array.ts
@@ -158,6 +158,19 @@ describe('Array types', () => {
     );
   });
 
+  it('should respect disableStackTrace', async () => {
+    let inst = array().of(object({ str: string().required() }));
+
+    const data = [{ str: undefined }, { str: undefined }];
+    return Promise.all([
+      expect(inst.strict().validate(data)).rejects.toHaveProperty('stack'),
+
+      expect(
+        inst.strict().validate(data, { disableStackTrace: true }),
+      ).rejects.not.toHaveProperty('stack'),
+    ]);
+  });
+
   it('should compact arrays', () => {
     let arr = ['', 1, 0, 4, false, null],
       inst = array();
diff --git a/test/mixed.ts b/test/mixed.ts
index 687476b..6cb64e3 100644
--- a/test/mixed.ts
+++ b/test/mixed.ts
@@ -338,6 +338,16 @@ describe('Mixed Types ', () => {
     ]);
   });
 
+  it('should respect disableStackTrace', () => {
+    // let inst = string().trim();
+    // return Promise.all([
+    //   expect(inst.strict().validate(' hi ')).rejects.toHaveProperty('stack'),
+    //   expect(
+    //     inst.strict().validate(' hi ', { disableStackTrace: true }),
+    //   ).not.toHaveProperty('stack'),
+    // ]);
+  });
+
   it('should overload test()', () => {
     let inst = mixed().test('test', noop);
 
@@ -967,11 +977,10 @@ describe('Mixed Types ', () => {
             then: (s) => s.defined(),
           }),
         baz: tuple([string(), number()]),
-      })
-      .when(['dummy'], (_, s) => {
+      }).when(['dummy'], (_, s) => {
         return s.shape({
-          when: string()
-        })
+          when: string(),
+        });
       });
     });
 
@@ -1201,7 +1210,7 @@ describe('Mixed Types ', () => {
             oneOf: [],
             optional: true,
             tests: [],
-          }
+          },
         },
       });
     });
diff --git a/test/string.ts b/test/string.ts
index ab0ad3e..f8c24b5 100644
--- a/test/string.ts
+++ b/test/string.ts
@@ -1,6 +1,13 @@
 import * as TestHelpers from './helpers';
 
-import { string, number, object, ref } from '../src';
+import {
+  string,
+  number,
+  object,
+  ref,
+  ValidationError,
+  AnySchema,
+} from '../src';
 
 describe('String types', () => {
   describe('casting', () => {
@@ -225,6 +232,110 @@ describe('String types', () => {
     ]);
   });
 
+  describe('DATETIME', function () {
+    it('should check DATETIME correctly', function () {
+      let v = string().datetime();
+
+      return Promise.all([
+        expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
+        expect(v.isValid('1977-00-28T12:34:56.0Z')).resolves.toBe(true),
+        expect(v.isValid('1900-10-29T12:34:56.00Z')).resolves.toBe(true),
+        expect(v.isValid('1000-11-30T12:34:56.000Z')).resolves.toBe(true),
+        expect(v.isValid('4444-12-31T12:34:56.0000Z')).resolves.toBe(true),
+
+        // Should not allow time zone offset by default
+        expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(false),
+        expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(false),
+        expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(false),
+
+        expect(v.isValid('this is not a datetime')).resolves.toBe(false),
+        expect(v.isValid('2023-08-16T12:34:56')).resolves.toBe(false),
+        expect(v.isValid('2023-08-1612:34:56Z')).resolves.toBe(false),
+        expect(v.isValid('1970-01-01 00:00:00Z')).resolves.toBe(false),
+        expect(v.isValid('1970-01-01T00:00:00,000Z')).resolves.toBe(false),
+        expect(v.isValid('1970-01-01T0000')).resolves.toBe(false),
+        expect(v.isValid('1970-01-01T00:00.000')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
+        expect(v.isValid('2023-08-16')).resolves.toBe(false),
+        expect(v.isValid('1970-as-df')).resolves.toBe(false),
+        expect(v.isValid('19700101')).resolves.toBe(false),
+        expect(v.isValid('197001')).resolves.toBe(false),
+      ]);
+    });
+
+    it('should support DATETIME allowOffset option', function () {
+      let v = string().datetime({ allowOffset: true });
+
+      return Promise.all([
+        expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
+        expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(true),
+        expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(true),
+        expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(true),
+        expect(v.isValid('1970-01-01T00:00:00+0630')).resolves.toBe(true),
+      ]);
+    });
+
+    it('should support DATETIME precision option', function () {
+      let v = string().datetime({ precision: 4 });
+
+      return Promise.all([
+        expect(v.isValid('2023-01-09T12:34:56.0000Z')).resolves.toBe(true),
+        expect(v.isValid('2023-01-09T12:34:56.00000Z')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56.000Z')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56.00Z')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56.0Z')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
+        expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(false),
+        expect(v.isValid('2010-04-10T14:06:14.0000+00:00')).resolves.toBe(
+          false,
+        ),
+      ]);
+    });
+
+    describe('DATETIME error strings', function () {
+      function getErrorString(schema: AnySchema, value: string) {
+        try {
+          schema.validateSync(value);
+          fail('should have thrown validation error');
+        } catch (e) {
+          const err = e as ValidationError;
+          return err.errors[0];
+        }
+      }
+
+      it('should use the default locale string on error', function () {
+        let v = string().datetime();
+        expect(getErrorString(v, 'asdf')).toBe(
+          'this must be a valid ISO date-time',
+        );
+      });
+
+      it('should use the allowOffset locale string on error when offset caused error', function () {
+        let v = string().datetime();
+        expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(
+          'this must be a valid ISO date-time with UTC "Z" timezone',
+        );
+      });
+
+      it('should use the precision locale string on error when precision caused error', function () {
+        let v = string().datetime({ precision: 2 });
+        expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(
+          'this must be a valid ISO date-time with a sub-second precision of exactly 2 digits',
+        );
+      });
+
+      it('should prefer options.message over all default error messages', function () {
+        let msg = 'hello';
+        let v = string().datetime({ message: msg });
+        expect(getErrorString(v, 'asdf')).toBe(msg);
+        expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(msg);
+
+        v = string().datetime({ message: msg, precision: 2 });
+        expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(msg);
+      });
+    });
+  });
+
   xit('should check allowed values at the end', () => {
     return Promise.all([
       expect(