Repository: colinhacks/zod
Pull request #4197 (2025-04-16 17:40:09)

feat(locale): add russian language
Added Russian locale

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Draft detected.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=colinhacks/zod&utm_content=4197):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
VladSez: tests are failing, after I added [tests](https://github.com/colinhacks/zod/blob/93e73d9ea883229847b05c174743df6cb9e60e51/packages/core/src/locales/__tests__/ru.test.ts) for Russian plurals, any ideas how to fix this?
https://github.com/colinhacks/zod/actions/runs/14500622148/job/40679503126?pr=4197#step:8:170

<img width="1440" alt="image" src="https://github.com/user-attachments/assets/d609c48b-2543-497a-9b21-1d8972bfa540" />

I can remove the tests if needed

RobinTail: > tests are failing, after I added [tests](https://github.com/colinhacks/zod/blob/93e73d9ea883229847b05c174743df6cb9e60e51/packages/core/src/locales/__tests__/ru.test.ts) for Russian plurals, any ideas how to fix this? 

try moving it here:
https://github.com/colinhacks/zod/tree/93e73d9ea883229847b05c174743df6cb9e60e51/packages/core/tests/locales

@VladSez 

colinhacks: You don't need to add per-locale tests in a separate file. I'll add something later that iterates through all the locales and does some snapshot testing. 
VladSez: Should I remove the tests? The idea was to test Russian plurals specifically, since they can be a bit tricky
colinhacks: If you feel this is specifically worth testing then I believe you! You'll have to put it in `packages/core/tests` to fix the CI I think. 

Patch:
diff --git a/packages/core/src/locales.ts b/packages/core/src/locales.ts
index c79b36cb..21ff0c85 100644
--- a/packages/core/src/locales.ts
+++ b/packages/core/src/locales.ts
@@ -3,6 +3,7 @@ import az from "./locales/az.js";
 import cs from "./locales/cs.js";
 import en from "./locales/en.js";
 import es from "./locales/es.js";
+import ru from "./locales/ru.js";
 import tr from "./locales/tr.js";
 import id from "./locales/id.js";
 import it from "./locales/it.js";
@@ -17,4 +18,4 @@ import fr from "./locales/fr.js";
 import ja from "./locales/ja.js";
 import pt from "./locales/pt.js";
 
-export { ar, az, cs, es, en, fi, he, hu, id, it, pt, tr, ja, fr, pl, ua, vi, zhCN };
+export { ar, az, cs, es, en, fi, fr, he, hu, id, it, ja, pl, pt, ru, tr, ua, vi, zhCN };
diff --git a/packages/core/src/locales/ru.ts b/packages/core/src/locales/ru.ts
new file mode 100644
index 00000000..6f0119db
--- /dev/null
+++ b/packages/core/src/locales/ru.ts
@@ -0,0 +1,185 @@
+import type { $ZodStringFormats } from "../checks.js";
+import type * as errors from "../errors.js";
+import * as util from "../util.js";
+
+function getRussianPlural(count: number, one: string, few: string, many: string): string {
+  const absCount = Math.abs(count);
+  const lastDigit = absCount % 10;
+  const lastTwoDigits = absCount % 100;
+
+  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
+    return many;
+  }
+
+  if (lastDigit === 1) {
+    return one;
+  }
+
+  if (lastDigit >= 2 && lastDigit <= 4) {
+    return few;
+  }
+
+  return many;
+}
+
+interface RussianSizable {
+  unit: {
+    one: string;
+    few: string;
+    many: string;
+  };
+  verb: string;
+}
+
+const Sizable: Record<string, RussianSizable> = {
+  string: {
+    unit: {
+      one: "символ",
+      few: "символа",
+      many: "символов",
+    },
+    verb: "иметь",
+  },
+  file: {
+    unit: {
+      one: "байт",
+      few: "байта",
+      many: "байт",
+    },
+    verb: "иметь",
+  },
+  array: {
+    unit: {
+      one: "элемент",
+      few: "элемента",
+      many: "элементов",
+    },
+    verb: "иметь",
+  },
+  set: {
+    unit: {
+      one: "элемент",
+      few: "элемента",
+      many: "элементов",
+    },
+    verb: "иметь",
+  },
+};
+
+function getSizing(origin: string): RussianSizable | null {
+  return Sizable[origin] ?? null;
+}
+
+export const parsedType = (data: any): string => {
+  const t = typeof data;
+
+  switch (t) {
+    case "number": {
+      return Number.isNaN(data) ? "NaN" : "число";
+    }
+    case "object": {
+      if (Array.isArray(data)) {
+        return "массив";
+      }
+      if (data === null) {
+        return "null";
+      }
+
+      if (Object.getPrototypeOf(data) !== Object.prototype && data.constructor) {
+        return data.constructor.name;
+      }
+    }
+  }
+  return t;
+};
+
+const Nouns: {
+  [k in $ZodStringFormats | (string & {})]?: string;
+} = {
+  regex: "ввод",
+  email: "email адрес",
+  url: "URL",
+  emoji: "эмодзи",
+  uuid: "UUID",
+  uuidv4: "UUIDv4",
+  uuidv6: "UUIDv6",
+  nanoid: "nanoid",
+  guid: "GUID",
+  cuid: "cuid",
+  cuid2: "cuid2",
+  ulid: "ULID",
+  xid: "XID",
+  ksuid: "KSUID",
+  datetime: "ISO дата и время",
+  date: "ISO дата",
+  time: "ISO время",
+  duration: "ISO длительность",
+  ipv4: "IPv4 адрес",
+  ipv6: "IPv6 адрес",
+  cidrv4: "IPv4 диапазон",
+  cidrv6: "IPv6 диапазон",
+  base64: "строка в формате base64",
+  base64url: "строка в формате base64url",
+  json_string: "JSON строка",
+  e164: "номер E.164",
+  jwt: "JWT",
+  template_literal: "ввод",
+};
+
+const error: errors.$ZodErrorMap = (issue) => {
+  switch (issue.code) {
+    case "invalid_type":
+      return `Неверный ввод: ожидалось ${issue.expected}, получено ${parsedType(issue.input)}`;
+    case "invalid_value":
+      if (issue.values.length === 1) return `Неверный ввод: ожидалось ${util.stringifyPrimitive(issue.values[0])}`;
+      return `Неверный вариант: ожидалось одно из ${util.joinValues(issue.values, "|")}`;
+    case "too_big": {
+      const adj = issue.inclusive ? "<=" : "<";
+      const sizing = getSizing(issue.origin);
+      if (sizing) {
+        const maxValue = Number(issue.maximum);
+        const unit = getRussianPlural(maxValue, sizing.unit.one, sizing.unit.few, sizing.unit.many);
+        return `Слишком большое значение: ожидалось, что ${issue.origin ?? "значение"} будет иметь ${adj}${issue.maximum.toString()} ${unit}`;
+      }
+      return `Слишком большое значение: ожидалось, что ${issue.origin ?? "значение"} будет ${adj}${issue.maximum.toString()}`;
+    }
+    case "too_small": {
+      const adj = issue.inclusive ? ">=" : ">";
+      const sizing = getSizing(issue.origin);
+      if (sizing) {
+        const minValue = Number(issue.minimum);
+        const unit = getRussianPlural(minValue, sizing.unit.one, sizing.unit.few, sizing.unit.many);
+        return `Слишком маленькое значение: ожидалось, что ${issue.origin} будет иметь ${adj}${issue.minimum.toString()} ${unit}`;
+      }
+      return `Слишком маленькое значение: ожидалось, что ${issue.origin} будет ${adj}${issue.minimum.toString()}`;
+    }
+    case "invalid_format": {
+      const _issue = issue as errors.$ZodStringFormatIssues;
+      if (_issue.format === "starts_with") return `Неверная строка: должна начинаться с "${_issue.prefix}"`;
+      if (_issue.format === "ends_with") return `Неверная строка: должна заканчиваться на "${_issue.suffix}"`;
+      if (_issue.format === "includes") return `Неверная строка: должна содержать "${_issue.includes}"`;
+      if (_issue.format === "regex") return `Неверная строка: должна соответствовать шаблону ${_issue.pattern}`;
+      return `Неверный ${Nouns[_issue.format] ?? issue.format}`;
+    }
+    case "not_multiple_of":
+      return `Неверное число: должно быть кратным ${issue.divisor}`;
+    case "unrecognized_keys":
+      return `Нераспознанн${issue.keys.length > 1 ? "ые" : "ый"} ключ${issue.keys.length > 1 ? "и" : ""}: ${util.joinValues(issue.keys, ", ")}`;
+    case "invalid_key":
+      return `Неверный ключ в ${issue.origin}`;
+    case "invalid_union":
+      return "Неверные входные данные";
+    case "invalid_element":
+      return `Неверное значение в ${issue.origin}`;
+    default:
+      return `Неверные входные данные`;
+  }
+};
+
+export { error };
+
+export default function (): { localeError: errors.$ZodErrorMap } {
+  return {
+    localeError: error,
+  };
+}
diff --git a/packages/docs/content/error-customization.mdx b/packages/docs/content/error-customization.mdx
index 2e0cd2fe..51b07247 100644
--- a/packages/docs/content/error-customization.mdx
+++ b/packages/docs/content/error-customization.mdx
@@ -358,6 +358,8 @@ The following locales are available:
 - `ar` — Arabic
 - `az` — Azerbaijani
 - `en` — English
+- `es` — Spanish
+- `ru` — Russian
 - `tr` — Türkçe
 - `it` — Italian
 - `cs` - Czech


Test patch:
diff --git a/packages/core/tests/locales/ru.test.ts b/packages/core/tests/locales/ru.test.ts
new file mode 100644
index 00000000..8abe6d72
--- /dev/null
+++ b/packages/core/tests/locales/ru.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+import ru from "../../src/locales/ru.js";
+
+describe("Russian localization", () => {
+  const { localeError } = ru();
+
+  describe("pluralization rules", () => {
+    for (const { type, cases } of TEST_CASES) {
+      describe(`${type} pluralization`, () => {
+        for (const { count, expected } of cases) {
+          it(`correctly pluralizes ${count} ${type}`, () => {
+            const error = localeError({
+              code: "too_small",
+              minimum: count,
+              type: "number",
+              inclusive: true,
+              path: [],
+              origin: type,
+              input: count - 1,
+            });
+
+            expect(error).toContain(expected);
+          });
+        }
+      });
+    }
+
+    it("handles negative numbers correctly", () => {
+      const error = localeError({
+        code: "too_small",
+        minimum: -2,
+        type: "number",
+        inclusive: true,
+        path: [],
+        origin: "array",
+        input: -3,
+      });
+
+      expect(error).toContain("-2 элемента");
+    });
+
+    it("handles zero correctly", () => {
+      const error = localeError({
+        code: "too_small",
+        minimum: 0,
+        type: "number",
+        inclusive: true,
+        path: [],
+        origin: "array",
+        input: -1,
+      });
+
+      expect(error).toContain("0 элементов");
+    });
+
+    it("handles bigint values correctly", () => {
+      const error = localeError({
+        code: "too_small",
+        minimum: BigInt(21),
+        type: "number",
+        inclusive: true,
+        path: [],
+        origin: "array",
+        input: BigInt(20),
+      });
+
+      expect(error).toContain("21 элемент");
+    });
+  });
+});
+
+const TEST_CASES = [
+  {
+    type: "array",
+    cases: [
+      { count: 1, expected: "1 элемент" },
+      { count: 2, expected: "2 элемента" },
+      { count: 5, expected: "5 элементов" },
+      { count: 11, expected: "11 элементов" },
+      { count: 21, expected: "21 элемент" },
+      { count: 22, expected: "22 элемента" },
+      { count: 25, expected: "25 элементов" },
+      { count: 101, expected: "101 элемент" },
+      { count: 111, expected: "111 элементов" },
+    ],
+  },
+  {
+    type: "set",
+    cases: [
+      { count: 1, expected: "1 элемент" },
+      { count: 2, expected: "2 элемента" },
+      { count: 5, expected: "5 элементов" },
+      { count: 11, expected: "11 элементов" },
+      { count: 21, expected: "21 элемент" },
+      { count: 22, expected: "22 элемента" },
+      { count: 25, expected: "25 элементов" },
+      { count: 101, expected: "101 элемент" },
+      { count: 111, expected: "111 элементов" },
+    ],
+  },
+  {
+    type: "string",
+    cases: [
+      { count: 1, expected: "1 символ" },
+      { count: 2, expected: "2 символа" },
+      { count: 5, expected: "5 символов" },
+      { count: 11, expected: "11 символов" },
+      { count: 21, expected: "21 символ" },
+      { count: 22, expected: "22 символа" },
+      { count: 25, expected: "25 символов" },
+    ],
+  },
+  {
+    type: "file",
+    cases: [
+      { count: 0, expected: "0 байт" },
+      { count: 1, expected: "1 байт" },
+      { count: 2, expected: "2 байта" },
+      { count: 5, expected: "5 байт" },
+      { count: 11, expected: "11 байт" },
+      { count: 21, expected: "21 байт" },
+      { count: 22, expected: "22 байта" },
+      { count: 25, expected: "25 байт" },
+      { count: 101, expected: "101 байт" },
+      { count: 110, expected: "110 байт" },
+    ],
+  },
+] as const;