Skip to content

Replace hand-rolled config normalization/validation with a modern schema lib (zod) #39

Description

@kfastov

Problem

core/config.js hand-rolls config normalization + validation (~50 lines): normalizeValue (trim), normalizeBoolean (parse true/1/yes/y/on), per-key alias resolution (nested mcp.host / flat mcpHost / snake mcp_host), port coercion, per-section defaults, and a validateConfig that returns a missing[] array. It's exactly the "reinvent the wheel" glue we keep growing — and it tempts over-abstraction (we recently added then reverted a normalizeServerSection helper because parameterizing two sections cost more than it saved).

Per project philosophy — prefer modern, popular, well-maintained, great-DX packages (the way got superseded request, or mtcute is a fresher take than traditional MTProto libs) over old "battle-tested but poor-DX" ones — we should replace this glue with a declarative schema lib.

Goal

Replace the ~50 lines of normalization + validation in core/config.js with a declarative schema. Keep the existing ~25-line loader/saver (file IO + resolveStoreDir() + ENOENT→null) — that part is already clean and framework-agnostic. This is a schema-validation problem, not a config-framework problem.

Recommended approach: zod (v4)

Live npm data (2026-06-02): zod v4.4.3, ~185M downloads/week, 0 runtime deps, MIT, actively released. Highest-fidelity match to all our needs and the ecosystem default.

Sketch:

  • Aliasesz.preprocess(raw => canonicalKeys(raw), schema) folds the mcp.host/mcpHost/mcp_host trio into canonical keys in one step (replaces normalizeConfig).
  • Trim / defaults / portsz.string().trim(), .default('127.0.0.1') / .default(false), z.coerce.number().int().positive().
  • Booleans → a small z.preprocess/z.transform for the true/1/yes/on set. ⚠️ Do NOT use z.coerce.boolean() — it's plain truthiness so "false"true (zod#3924).
  • Errorsschema.safeParse() + z.prettifyError(err) for clear multi-line messages (replaces the missing[] array with better UX).
  • Plain-JS ESM works today; we just forgo static type inference (which we don't use anyway).

Runner-up: valibot (v1)

v1.4.1, ~11.8M/week, 0 deps, functional pipe API. Functionally equal; pick it only if we wanted its smaller footprint/style (bundle size is irrelevant for a Node CLI). Slightly more verbose for the nested alias-merge.

Why NOT a config framework

conf (sindresorhus), c12 (unjs), cosmiconfig, convict, node-config/rc solve the wrong axis — managed stores / multi-source layering / file discovery — for a tool that uses one JSON file at a known path with an env override. They'd add the file-location behavior we deliberately customized and still leave us hand-rolling alias mapping. Only revisit conf if we later want a managed, migrating, atomically-written store.

Acceptance criteria

  • normalizeConfig + validateConfig reimplemented declaratively via the chosen schema lib; behavior preserved (aliases, trim, boolean/port coercion, per-section defaults, required apiId/apiHash/phoneNumber).
  • Existing config tests stay green (tests/config-control.test.js, tests/cli-auth.test.js, any mcp/control normalization tests) — normalized output byte-identical for the covered cases.
  • Clear validation errors (e.g. apiId is required, control.port must be a positive integer).
  • The loader/saver (file IO + store dir + ENOENT) left intact.
  • Dependency pinned; one-line note documenting the z.coerce.boolean footgun avoided.

Supply-chain note

zod is single-maintainer (colinhacks) but 0 runtime deps → minimal auditable surface, and ~185M weekly downloads = heavy scrutiny; pin the version. valibot similar (single maintainer, 0 deps, fewer eyes). conf would pull ~9 transitive deps (ajv chain). Figures fetched live from registry.npmjs.org / api.npmjs.org on 2026-06-02.

Scope

Standalone cleanup PR — not part of the #30 backfill work. The current control.* config (added in PR-2a) and mcp.* are the two sections to model.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions