From 602a2c1fb5ba69642c324927645c02e22a24bf1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:39:21 +0000 Subject: [PATCH] Zod: Detect Configured Locale for Custom Messages Replace the internal setLang() with detection of the locale Zod is configured with. Zod only stores the configured locale's error map, so getLocale() probes it with a known issue and compares the message to the ones our supported locales give, caching the result per error map. Detection runs lazily when an issue is finalized, so our custom messages appear for Zod Classic's default en locale or after z.config(en()) in Zod Mini, regardless of whether z.config() runs before or after import. With no locale or an unsupported one, Zod's own messages are used. Supported locales are typed as a subset of the locale names Zod exports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bb6sv3VomENtuqfvmd7rcF Signed-off-by: Claude --- packages/zod/src/v20170710/lang/_internal.ts | 61 ++++++-- packages/zod/src/v20170710/lang/index.ts | 1 - packages/zod/tests/v20170710/base.test.ts | 2 - packages/zod/tests/v20170710/lang.test.ts | 132 ++++++++++++++++++ packages/zod/tests/v20170710/reviews.test.ts | 4 + .../spaced-repetition-systems.test.ts | 2 - .../tests/v20170710/study-materials.test.ts | 2 - packages/zod/tests/v20170710/subjects.test.ts | 2 - packages/zod/tests/v20170710/user.test.ts | 2 - vitest.config.ts | 2 +- 10 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 packages/zod/tests/v20170710/lang.test.ts diff --git a/packages/zod/src/v20170710/lang/_internal.ts b/packages/zod/src/v20170710/lang/_internal.ts index 8b47a8a6..1aa71b95 100644 --- a/packages/zod/src/v20170710/lang/_internal.ts +++ b/packages/zod/src/v20170710/lang/_internal.ts @@ -1,20 +1,57 @@ -let lang: string | undefined; +import * as z from "zod/mini"; +import type * as core from "zod/v4/core"; +import type * as locales from "zod/v4/locales"; +import { en } from "zod/v4/locales"; -export type Locale = "en"; +/** The names of the locales built into Zod, as exported from `zod/locales` (e.g. `en`, `ja`, `frCA`) */ +type ZodLocale = keyof typeof locales; + +/** The subset of Zod's built-in locales that this package has custom messages for */ +type Locale = Extract; + +type LocaleFactory = () => { localeError: core.$ZodErrorMap }; + +const supportedLocales: readonly (readonly [Locale, LocaleFactory])[] = [["en", en]]; + +/* +N.b. Zod doesn't record which locale was passed to `z.config()`; it only keeps the locale's error map. To figure out +which locale is active, we feed this issue to the configured error map and compare its message against the message +each of our supported locales gives for the same issue. +*/ +const probeIssue: core.$ZodRawIssue = { code: "invalid_type", expected: "string", input: 0 }; + +let fingerprints: ReadonlyMap | undefined; +const detectedLocales = new WeakMap(); + +function getMessage(errorMap: core.$ZodErrorMap): string | undefined { + const result = errorMap(probeIssue); + return typeof result === "string" ? result : result?.message; +} + +function getFingerprints(): ReadonlyMap { + fingerprints ??= new Map(supportedLocales.map(([locale, factory]) => [getMessage(factory().localeError), locale])); + return fingerprints; +} /** - * Set the language for this package's custom messages + * Get the locale Zod is currently configured with, if this package has custom messages for it * - * @param value The RFC-5646 tag to set the preferred locale to + * Zod Classic configures the `en` locale by default, while Zod Mini has no locale configured until something like + * `z.config(z.locales.en())` is run. Detection is done lazily, i.e. when an issue is being finalized, so `z.config()` + * may be called before or after this package is imported. + * + * @returns The configured locale, or `undefined` if no locale is configured or it isn't supported by this package */ -export function setLang(value: string | undefined): void { - lang = value; -} - // @__NO_SIDE_EFFECTS__ -export function getLocale(): Locale | undefined { - if (lang === undefined || lang === "en" || lang.startsWith("en-")) { - return "en"; +function getLocale(): Locale | undefined { + const { localeError } = z.config(); + if (localeError === undefined) { + return undefined; + } + if (!detectedLocales.has(localeError)) { + detectedLocales.set(localeError, getFingerprints().get(getMessage(localeError))); } - return undefined; + return detectedLocales.get(localeError); } + +export { getLocale, type Locale }; diff --git a/packages/zod/src/v20170710/lang/index.ts b/packages/zod/src/v20170710/lang/index.ts index d86daf7a..5c446639 100644 --- a/packages/zod/src/v20170710/lang/index.ts +++ b/packages/zod/src/v20170710/lang/index.ts @@ -1,4 +1,3 @@ export { default as dateUnion } from "./date-union.ts"; export { default as reviewPayloadUnion } from "./review-payload-union.ts"; export { default as subjectTupleDuplicate } from "./subject-tuple-duplicate.ts"; -export { setLang } from "./_internal.ts"; diff --git a/packages/zod/tests/v20170710/base.test.ts b/packages/zod/tests/v20170710/base.test.ts index fa81bd92..0659dac9 100644 --- a/packages/zod/tests/v20170710/base.test.ts +++ b/packages/zod/tests/v20170710/base.test.ts @@ -3,11 +3,9 @@ import { describe, expect } from "vitest"; import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; -import { setLang } from "../../src/v20170710/lang/_internal.ts"; import testFor from "./fixtures.js"; z.config(en()); -setLang("en"); describe("ApiRevision", () => { testFor("Valid WaniKani API Revision", ({ apiRevision }) => { diff --git a/packages/zod/tests/v20170710/lang.test.ts b/packages/zod/tests/v20170710/lang.test.ts new file mode 100644 index 00000000..62e4641c --- /dev/null +++ b/packages/zod/tests/v20170710/lang.test.ts @@ -0,0 +1,132 @@ +import * as classic from "zod"; +import * as z from "zod/v4/core"; +import { describe, expect } from "vitest"; +import { en, ja } from "zod/locales"; + +import * as WaniKani from "../../src/v20170710/index.js"; +import { getLocale } from "../../src/v20170710/lang/_internal.ts"; +import testFor from "./fixtures.js"; + +const messages = { + dateUnion: "Expected either a valid ISO-8601 timestamp string or a JavaScript Date", + reviewPayloadUnion: + "Review Payload must have one and only one of either an assignment_id or subject_id positive integer", + subjectTupleDuplicate: "Duplicate Subject Type detected in Subject Tuple", +}; + +const badReviewPayload = { + review: { assignment_id: 1, subject_id: undefined, incorrect_meaning_answers: 0, incorrect_reading_answers: 0 }, +}; + +function getMessage(schema: z.$ZodType, value: unknown): string | undefined { + return z.safeParse(schema, value).error?.issues[0]?.message; +} + +function useZodClassicDefaultLocale(): void { + z.config({ localeError: undefined }); + // Zod Classic configures its default locale when the first schema is constructed + classic.string(); +} + +describe("getLocale", () => { + testFor("Returns undefined when no locale is configured", () => { + z.config({ localeError: undefined }); + expect(getLocale()).toBeUndefined(); + }); + testFor("Returns en when Zod is configured with the en locale", () => { + z.config(en()); + expect(getLocale()).toBe("en"); + }); + testFor("Returns en when Zod Classic sets its default locale", () => { + useZodClassicDefaultLocale(); + expect(getLocale()).toBe("en"); + }); + testFor("Returns undefined when Zod is configured with an unsupported locale", () => { + z.config(ja()); + expect(getLocale()).toBeUndefined(); + }); + testFor("Returns undefined for a custom locale error map", () => { + z.config({ localeError: () => "Custom locale message" }); + expect(getLocale()).toBeUndefined(); + }); + testFor("Returns undefined for a custom locale error map with no message", () => { + z.config({ localeError: () => null }); + expect(getLocale()).toBeUndefined(); + }); + testFor("Returns en for a locale error map giving en messages as objects", () => { + const { localeError } = en(); + z.config({ + localeError: (issue) => { + const message = localeError(issue); + return typeof message === "string" ? { message } : message; + }, + }); + expect(getLocale()).toBe("en"); + }); + testFor("Follows changes to the configured locale", () => { + const enLocale = en(); + z.config(enLocale); + expect(getLocale()).toBe("en"); + z.config(ja()); + expect(getLocale()).toBeUndefined(); + z.config(enLocale); + expect(getLocale()).toBe("en"); + z.config({ localeError: undefined }); + expect(getLocale()).toBeUndefined(); + }); +}); + +describe("Custom messages", () => { + testFor( + "Are used when Zod is configured with the en locale", + ({ collectionParamsWithBadDatableStringUnion, repeatedSubjectTuple }) => { + z.config(en()); + expect(getMessage(WaniKani.CollectionParameters, collectionParamsWithBadDatableStringUnion)).toBe( + messages.dateUnion, + ); + expect(getMessage(WaniKani.ReviewPayload, badReviewPayload)).toBe(messages.reviewPayloadUnion); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe(messages.subjectTupleDuplicate); + }, + ); + testFor( + "Are used when Zod Classic sets its default locale", + ({ collectionParamsWithBadDatableStringUnion, repeatedSubjectTuple }) => { + useZodClassicDefaultLocale(); + expect(getMessage(WaniKani.CollectionParameters, collectionParamsWithBadDatableStringUnion)).toBe( + messages.dateUnion, + ); + expect(getMessage(WaniKani.ReviewPayload, badReviewPayload)).toBe(messages.reviewPayloadUnion); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe(messages.subjectTupleDuplicate); + }, + ); + testFor( + "Are not used when no locale is configured", + ({ collectionParamsWithBadDatableStringUnion, repeatedSubjectTuple }) => { + z.config({ localeError: undefined }); + expect(getMessage(WaniKani.CollectionParameters, collectionParamsWithBadDatableStringUnion)).toBe( + "Invalid input", + ); + expect(getMessage(WaniKani.ReviewPayload, badReviewPayload)).toBe("Invalid input"); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe("Invalid input"); + }, + ); + testFor( + "Defer to Zod for an unsupported locale", + ({ collectionParamsWithBadDatableStringUnion, repeatedSubjectTuple }) => { + z.config({ localeError: () => "Custom locale message" }); + expect(getMessage(WaniKani.CollectionParameters, collectionParamsWithBadDatableStringUnion)).toBe( + "Custom locale message", + ); + expect(getMessage(WaniKani.ReviewPayload, badReviewPayload)).toBe("Custom locale message"); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe("Custom locale message"); + }, + ); + testFor("Follow the locale configured after the schemas were created", ({ repeatedSubjectTuple }) => { + z.config({ localeError: undefined }); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe("Invalid input"); + z.config(en()); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).toBe(messages.subjectTupleDuplicate); + z.config(ja()); + expect(getMessage(WaniKani.SubjectTuple, repeatedSubjectTuple)).not.toBe(messages.subjectTupleDuplicate); + }); +}); diff --git a/packages/zod/tests/v20170710/reviews.test.ts b/packages/zod/tests/v20170710/reviews.test.ts index 03f3ab44..83616024 100644 --- a/packages/zod/tests/v20170710/reviews.test.ts +++ b/packages/zod/tests/v20170710/reviews.test.ts @@ -1,8 +1,12 @@ +import * as z from "zod/v4/core"; import { describe, expect } from "vitest"; +import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; import testFor from "./fixtures.js"; +z.config(en()); + describe("Review", () => { testFor("Review from WaniKani API Docs", ({ review }) => { expect(() => WaniKani.Review.parse(review)).not.toThrow(); diff --git a/packages/zod/tests/v20170710/spaced-repetition-systems.test.ts b/packages/zod/tests/v20170710/spaced-repetition-systems.test.ts index b637669c..10c546e4 100644 --- a/packages/zod/tests/v20170710/spaced-repetition-systems.test.ts +++ b/packages/zod/tests/v20170710/spaced-repetition-systems.test.ts @@ -3,11 +3,9 @@ import { describe, expect } from "vitest"; import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; -import { setLang } from "../../src/v20170710/lang/_internal.ts"; import testFor from "./fixtures.js"; z.config(en()); -setLang("en"); describe("SpacedRepetitionSystemStageNumber", () => { testFor(`Invalid SRS Stage Number: ${WaniKani.MIN_SRS_STAGE - 1}`, () => { diff --git a/packages/zod/tests/v20170710/study-materials.test.ts b/packages/zod/tests/v20170710/study-materials.test.ts index c02e8dc7..8f4f2158 100644 --- a/packages/zod/tests/v20170710/study-materials.test.ts +++ b/packages/zod/tests/v20170710/study-materials.test.ts @@ -3,11 +3,9 @@ import { describe, expect } from "vitest"; import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; -import { setLang } from "../../src/v20170710/lang/_internal.ts"; import testFor from "./fixtures.js"; z.config(en()); -setLang("en"); describe("StudyMaterial", () => { testFor("Real StudyMaterial", ({ studyMaterial }) => { diff --git a/packages/zod/tests/v20170710/subjects.test.ts b/packages/zod/tests/v20170710/subjects.test.ts index d2452b95..ab6cbf77 100644 --- a/packages/zod/tests/v20170710/subjects.test.ts +++ b/packages/zod/tests/v20170710/subjects.test.ts @@ -3,11 +3,9 @@ import { describe, expect } from "vitest"; import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; -import { setLang } from "../../src/v20170710/lang/_internal.ts"; import testFor from "./fixtures.js"; z.config(en()); -setLang("en"); describe("SubjectType", () => { testFor("Valid Subject Types", ({ subjectTypes }) => { diff --git a/packages/zod/tests/v20170710/user.test.ts b/packages/zod/tests/v20170710/user.test.ts index 54b4a2a4..93fc040e 100644 --- a/packages/zod/tests/v20170710/user.test.ts +++ b/packages/zod/tests/v20170710/user.test.ts @@ -3,11 +3,9 @@ import { describe, expect } from "vitest"; import { en } from "zod/locales"; import * as WaniKani from "../../src/v20170710/index.js"; -import { setLang } from "../../src/v20170710/lang/_internal.ts"; import testFor from "./fixtures.js"; z.config(en()); -setLang("en"); describe("LessonBatchSizeNumber", () => { testFor(`Invalid Lesson Batch Size: ${WaniKani.MIN_LESSON_BATCH_SIZE - 1}`, () => { diff --git a/vitest.config.ts b/vitest.config.ts index 3f90d907..b750736c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ exclude: [ "packages/*/src/index.ts", "packages/*/src/v20170710/index.ts", - "packages/*/src/v20170710/lang/**", + "packages/valibot/src/v20170710/lang/**", "**/tests/**", "**/node_modules/**", ...coverageConfigDefaults.exclude,