Skip to content

Comments

Heyapi & zod4: 3 - upgrade zod#39

Open
publicJorn wants to merge 2 commits intomainfrom
heyapi-and-zod4/zod
Open

Heyapi & zod4: 3 - upgrade zod#39
publicJorn wants to merge 2 commits intomainfrom
heyapi-and-zod4/zod

Conversation

@publicJorn
Copy link
Member

The third PR in a series to upgrade the api generation tool to heyapi and upgrade zod.

Zod is currently a kind of glue, but NONE of the packages that use zod in this project are updated anymore.
Instead of 1 big PR that updates all, to keep it sane I will create multiple smaller PRs.

  • Upgrades zod to 4
  • Using native zod i18n

To test, add some validation to a component (eg. Home) and change language with the dropdown. Example:

	const validation = zLoginRequest.safeParse({
		email: "nope",
		password: "asdf1234",
	});

	return (
		<div className={style.page}>
			<H1 size="medium">{t("pages.home.title")}</H1>
			<p>Validation error(s):</p>
			<ul>
				{validation.error?.issues?.map((issue) => (
					<li
						key={issue.path.join(".")}
						style={{ marginLeft: "1rem" }}
					>
						<ErrorText>{issue.message}</ErrorText>
					</li>
				))}
			</ul>
		</div>
	);

Note: I could not get dynamic loading of zod locales to work. For now, with only 2 languages I think it is fine as they are small files. But if we ever need multiple languages, we should get deeper into it.
Possibly copying the zod/v4/locales folder including its dependencies to a relative folder and importing from there.

@publicJorn publicJorn requested a review from maanlamp as a code owner February 10, 2026 08:05
@publicJorn publicJorn changed the base branch from main to heyapi-and-zod4/heyapi February 10, 2026 08:06
@publicJorn publicJorn force-pushed the heyapi-and-zod4/heyapi branch from 43946c0 to 09c71a6 Compare February 10, 2026 10:11
@publicJorn publicJorn force-pushed the heyapi-and-zod4/heyapi branch from 09c71a6 to 3ed608d Compare February 10, 2026 10:22
@publicJorn publicJorn force-pushed the heyapi-and-zod4/zod branch 2 times, most recently from 6b1dec9 to bc17dee Compare February 10, 2026 11:06
maanlamp
maanlamp previously approved these changes Feb 10, 2026
Copy link
Collaborator

@maanlamp maanlamp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Seems like vite does not support dynamic imports with variable strings, so we can't natively lazy-load locales. However, I tried this plugin https://www.npmjs.com/package/vite-plugin-dynamic-import which allows dynamically importing basically anything, and got it to work using the example provided by the zod docs https://zod.dev/error-customization?id=internationalization

// vite.config.ts
import dynamicImport from 'vite-plugin-dynamic-import'

export default {
  plugins: [
    dynamicImport(/* options */)
  ]
}

// lib/i18n.ts
import { z } from "zod";
 
const loadLocale = async (locale: string) => {
  const { default: locale } = await import(`zod/v4/locales/${locale}.js`);
  z.config(locale());
};

// Usage 
await loadLocale("fr");

@publicJorn publicJorn force-pushed the heyapi-and-zod4/heyapi branch 3 times, most recently from 4dd604a to d470136 Compare February 10, 2026 14:58
Base automatically changed from heyapi-and-zod4/heyapi to main February 10, 2026 15:00
@publicJorn publicJorn dismissed maanlamp’s stale review February 10, 2026 15:00

The base branch was changed.

@publicJorn
Copy link
Member Author

publicJorn commented Feb 10, 2026

@maanlamp that certainly is an option, good find.

But some possible problems:

  • Visible zod error text doesn't get updated, only when validation is triggered again (minor)
  • If no i18n is needed, people will need to know to also delete this plugin.
  • Edge case: some lang files in zod are full locales (eg. fr-CA.js), but most are only lang (nl.js). We would need to import both (one will fail) and use the one that works.
  • It generates quite some code & files that we'll never use.

I can achieve the same result without the plugin by adding a switch (the plugin does the same internally). I had this before but I didn't notice I had to re-trigger the validation.

What do you think about that?

const loadZodLocale = async (locale: string) => {
	let localeImport: any;
	switch (lng) {
		case "en-US":
			localeImport = await import("zod/v4/locales/en.js");
			break;
		case "nl-NL":
			localeImport = await import("zod/v4/locales/nl.js");
			break;
	}
	if (localeImport?.default) {
		z.config(localeImport.default());
	}
};

@maanlamp
Copy link
Collaborator

maanlamp commented Feb 11, 2026

Visible zod error text doesn't get updated, only when validation is triggered again (minor)

Yeah if we don't integrate zod into the react lifecycle it wont update after changing locales. Might be worth looking into before merging. Perhaps only ever update it when also changing react-i18nexts locale which does tell React to trigger a rerender?

If no i18n is needed, people will need to know to also delete this plugin.

I'm quite stringent about this; It's become a bit of an in-joke to call this Wouter's law: any software project will need i18n at some point. There's no point in not translating from the start, IMO. It's not a lot of work and it saves time when every app inevitably needs to be translated down the road. I'm not in favour of leaving this up to the whims of those working on the project.

Edge case: some lang files in zod are full locales (eg. fr-CA.js), but most are only lang (nl.js). We would need to import both (one will fail) and use the one that works.

Sadly the native LocaleMatcher proposal has still not landed, but formatjs provides a ponyfill: https://www.npmjs.com/package/@formatjs/intl-localematcher

We can read all translation files' names and provide that as the list of supported locales. That way, we can pick the best match for any given language tag:

import "@formatjs/intl-localematcher";

const supportedZodLocales = Object.fromEntries(
	Object.entries(
		import.meta.glob(
			"../../../node_modules/zod/v4/locales/[!index]*.js",
			{
				import: "default",
			},
		),
	).map(([key, mod]) => [
		key.match(/\/([^/]+)\.js$/)?.[1],
		mod,
	]),
);
const defaultLocale = "nl";

const loadZodLocale = async (locale: string) => {
  const locale = Intl.LocaleMatcher.match(
    [locale],
    Object.keys(supportedZodLocales),
    defaultLocale,
    { algorithm: "best fit" }
  );
  const config = await supportedZodLocales[locale]();
  z.config(config);
};

If required, we can do this at build time to keep the polyfill from increasing our bundle size. Maybe we can even integrate this with our general i18n locale solution.

It generates quite some code & files that we'll never use.

If we need it for our code to work (ergonomically), I don't see it as "code that we'll never use". I do think we can do without the plugin, as it is possible to glob node_modules; we just have to use relative paths with explicit extension.

What do you think about that?

const loadZodLocale = async (locale: string) => {
  let localeImport: any;
  switch (lng) {
  	case "en-US":
  		localeImport = await import("zod/v4/locales/en.js");
  		break;
  	case "nl-NL":
  		localeImport = await import("zod/v4/locales/nl.js");
  		break;
  }
  if (localeImport?.default) {
  	z.config(localeImport.default());
  }
};

I'm more in favour of a plugin or the aforementioned glob solution. That way we automatically support what we need. No manual updating/referencing. I'm more than willing to pay for that with a bigger bundle.

@publicJorn
Copy link
Member Author

What you propose is cool. But I kind of get this idea 😆:

want-vs-got

Of all our projects that are multi-lingual, I think NONE have more than 2 languages to support (nl & en).
I really prefer to keep it simple. Even importing both en and nl as it was before, is smaller in size and way simpler than building the option to switch to all supported languages.

If you really feel strongly about this, we can build it in. But just know my preference.

@maanlamp
Copy link
Collaborator

The part I am missing in your approach is error / unhappy path handling.

The code you propose has no handling for locale typos / unsupported locales, and silently fails. Assuming that is not intentional, we could fix this by constraining the locale argument type from string to "en-US" | "nl-NL". That doesn't prevent a messed up state for the user if you call the function with an invalid locale anyway. The easiest solution for that would be to have one of the locales a default case.

In short:

const loadZodLocale = async (locale: "en-US" | "nl-NL") => {
	let localeImport: { default(): Partial<$ZodConfig> };
	switch (locale) {
		case "en-US":
			localeImport = await import("zod/v4/locales/en.js");
			break;
		default:
			localeImport = await import("zod/v4/locales/nl.js");
			break;
	}
	
	z.config(localeImport.default());
};

Anyway, I get the message; I also prefer practical solutions. In this case, I don't fully agree — 10 extra lines to support all locales automatically, with graceful fallback, is not an apache — but we don't have to agree. At least we are implementing translations by default, which I do feel strongly about.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants