Есть за мной такой грешок: если какая-то проблема мне долго досаждает, я в конце концов пишу библиотеку, которая её решает. На сей раз такая история возникла с кодом для валидации CLI.

Смотрите, я немало времени уделяю чтению кода, который написали другие люди. Это код опенсорсных проектов, всякий материал по работе, а также код из случайных репозиториев с GitHub, на которые, бывает, наткнёшься в два часа ночи. Причём, я то и дело замечаю одну и ту же проблему: в любом инструменте CLI найдётся одинаковый уродливый валидационный код, запрятанный поглубже. Знаете, в таком роде:

if (!opts.server && opts.port) {
  throw new Error("--port requires --server flag");
}

if (opts.server && !opts.port) {
  opts.port = 3000; // порт по умолчанию
}

// подождите, а что делать, если они передадут --port без значения?
// что, если порт находится за пределами допустимого диапазона?
// что, если...

Дело даже не в том, что такой код сложно писать. А в том, что он повсюду. В любом проекте. В каждом инструменте CLI. Одни и те же паттерны с небольшими вариациями. Опции, зависящие от других опций. Несочетаемые флаги. Аргументы, которые в определённых режимах не имеют смысла.

Причём вот что меня по-настоящему поразило: ведь для других типов данных эта проблема решена уже много лет назад. Только не для CLI.

Проблема с валидацией

Была одна статья, которая совершенно изменила моё представление о парсинге. Это «Парсь, не валидируй» Алексиса Кинга. В чём её суть? Нельзя разбирать данные в рыхлый тип, а затем проверять, валидны ли они. Их нужно разбирать именно в такой тип, который будет без вариантов валиден.

Задумайтесь об этом. Когда мы получаем JSON с API, мы не парсим его как any, а потом не записываем как ворох if-конструкций. Вероятно, вы воспользуетесь инструментом вроде Zod, чтобы распарсить данные именно в такую форму, какая вас интересует. Недопустимые данные? Тогда парсер их отклонит. И всё.

Но как мы поступаем в случае с CLI? Мы парсим аргументы, складывая их в некий мешок свойств, а затем в следующих 100 строках кода проверяем, есть ли смысл в содержимом этого мешка. А нужно поступать наоборот.

Так что, знаете, я разработал Optique. Не потому, что мир отчаянно нуждается в очередном парсере для CLI (нет, не нуждается), а потому, что я устал повсюду видеть — и писать — один и тот же валидационный код.

Три паттерна, которые я утомился валидировать

Зависимые опции

Встречается повсюду. У вас будет такая опция, которая имеет смысл, лишь если активирована какая-то другая опция.

Дедовский способ? Всё распарсить, а потом проверить:

const opts = parseArgs(process.argv);
if (!opts.server && opts.port) {
  throw new Error("--port requires --server");
}
if (opts.server && !opts.port) {
  opts.port = 3000;
}
// Пожалуй, где-то ещё вас поджидает дополнительная валидация...

Работая с Optique, вы просто описываете то, что вам нужно:

const config = withDefault(
  object({
    server: flag("--server"),
    port: option("--port", integer()),
    workers: option("--workers", integer())
  }),
  { server: false }
);

Вот что TypeScript позволяет вывести для типа config:

type Config = 
  | { readonly server: false }
  | { readonly server: true; readonly port: number; readonly workers: number }

Теперь система типов понимает, что, когда server равен falseport буквально не существует. Не undefined, не null— его просто нет. Попытайтесь к нему обратиться — и TypeScript будет ругаться. Никакой валидации во время выполнения не потребуется.

Взаимоисключающие опции

Ещё один классический пример. Вот вам форматы вывода на выбор: JSON, YAML или XML. Можно выбрать один, но определённо не два.

Я привык писать такую путаницу:

if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
  throw new Error('Choose only one output format');
}

(Не осуждайте меня, вы тоже писали что-то подобное.)

А теперь?

const format = or(
  map(option("--json"), () => "json" as const),
  map(option("--yaml"), () => "yaml" as const),
  map(option("--xml"), () => "xml" as const)
);

Комбинатор or() означает, что успешно выполнится лишь один вариант. В результате имеем просто "json" | "yaml" | "xml". Одна строка. Не приходится жонглировать тремя булевыми значениями.

Требования

В продакшене нужна аутентификация. В разработке нужны отладочные флаги. При работе в Docker нужны иные опции, нежели при работе на локальной машине. Вы и сами это знаете.

Чтобы не путаться с валидацией, просто опишите каждое из окружений:

const envConfig = or(
  object({
    env: constant("prod"),
    auth: option("--auth", string()),      // Required in prod
    ssl: option("--ssl"),
    monitoring: option("--monitoring", url())
  }),
  object({
    env: constant("dev"),
    debug: optional(option("--debug")),    // Optional in dev
    verbose: option("--verbose")
  })
);

Нет авторизации в продакшене? Парсер сразу же откажет. Попытались обратиться к --auth в режиме разработки? TypeScript вам этого не позволит — нужное вам поле в этом типе не существует.

«Но ведь комбинаторы парсера…»

Знаю, знаю. Термин «комбинаторы парсера» звучит так, как будто без учёной степени по информатике его не понять.

Вот в чём дело: у меня нет степени по информатике. Вообще никакой степени. Но я уже много лет пользуюсь комбинаторами парсеров, поскольку… не так они и сложны? Просто название у них такое, из-за которого они кажутся страшнее, чем есть на самом деле.

С их помощью я решаю и другие задачи — разбираю конфигурационные файлы, код на предметно-ориентированных языках (DSL) и пр. Но почему-то мне никогда не приходила в голову мысль применить их для парсинга CLI, пока я не увидел optparse-applicative в Haskell. Это был подлинный момент «постойте-ка, ну конечно». Вернее, почему мы вообще делаем это как-то иначе?

Всё оказалось просто до идиотизма. Парсер — это просто функция. Комбинаторы — это просто функции, принимающие парсеры и возвращающие новые парсеры. Вот и всё.

// Это парсер
const port = option("--port", integer());

// Это тоже парсер (составленный из более мелких парсеров)
const server = object({
  port: port,
  host: option("--host", string())
});

// Тоже парсер (парсер на парсере сидит и парсером погоняет)
const config = or(server, client);

Никаких монад. Никакой теории категорий. Просто функции. Скучные и красивые.

TypeScript берёт на себя тяжёлую работу

Ниже рассмотрю аспект, всё-таки отдающий читерством: дело в том, что я перестал писать типы для моих конфигураций CLI. TypeScript просто… сам с этим разбирается.

const cli = or(
  command("deploy", object({
    action: constant("deploy"),
    environment: argument(string()),
    replicas: option("--replicas", integer())
  })),
  command("rollback", object({
    action: constant("rollback"),
    version: argument(string()),
    force: option("--force")
  }))
);

// TypeScript автоматически выводит этот тип:
type Cli = 
  | { 
      readonly action: "deploy"
      readonly environment: string
      readonly replicas: number
    }
  | { 
      readonly action: "rollback"
      readonly version: string
      readonly force: boolean
    }

TypeScript известно, что, если action имеет значение "deploy", то environment  существует, а version — нет. Он знает, что replicas — это number. Знает, что force — это boolean. Я сам ничего этого не сообщал.

Речь не только о приятном автозавершении (хотя, да, с автозавершением здесь всё отлично). Речь об отлове багов до того, как они случатся. Забыли где-то обработать новую опцию? Тогда код не скомпилируется.

Что именно для меня изменилось

Я на несколько недель отдал эту библиотеку на внутреннее тестирование в нашей компании. Некоторые реальные отзывы:

Теперь я удаляю код. Не рефакторю. Удаляю. Где та логика валидации, занимавшая процентов 30 моего кода для CLI? Исчезла. К этому никак не привыкнуть.

Рефакторить не страшно. Знаете, что чего меня обычно бросает в дрожь? Если нужно менять механизм приёма аргументов в CLI. Например, менять --input file.txt на file.txt в качестве позиционного аргумента. Пользуясь при этом традиционными парсерами, приходится повсюду вылавливать логику валидации. А теперь? Просто меняешь определение парсера. TypeScript сразу же показывает тебе все места, где возникли неполадки, вы их точечно исправляете — готово. Раньше можно было битый час проверять «всё ли я выловил?», а теперь – «исправь там, где подчёркнуто красным, и двигайся далее».

Мои CLI стали причудливее. Когда при добавлении сложных опций не приходится городить валидацию, ты просто… добавляешь их. Взаимоисключающие группы? Конечно же. Опции, зависящие от контекста? Почему бы и нет. Парсер со всем справится.

Кроме того, код реально переиспользуется:

const networkOptions = object({
  host: option("--host", string()),
  port: option("--port", integer())
});

// Переиспользуем всё, только в других сочетаниях
const devServer = merge(networkOptions, debugOptions);
const prodServer = merge(networkOptions, authOptions);
const testServer = merge(networkOptions, mockOptions);

А если честно — что изменилось сильнее всего, так это степень доверия. Если код скомпилируется, то логика CLI будет работать. Не «наверное будет» или не «будет работать, если только кто-то не передаст странных аргументов». Работает и всё.

Нужно ли это вам?

Если вы пишете 10-строчный скрипт, принимающий один аргумент, то вам всё это не нужно. Напишите process.argv[2] — и можете этим удовлетвориться.

Но, если вам знакомы подобные ситуации:

  • Имеющаяся валидационная логика рассинхронизируется с фактически действующими опциями.

  • В продакшене было обнаружено, что определённые комбинации опций взрывоопасны.

  • Вы потратили остаток рабочего дня, чтобы отследить, почему --verbose ломается при использовании с —json.

  • В пятый раз пишете одну и ту же проверку «опция A требует опции B».

То, вероятно, вам стоит обратить внимание на мой инструмент.

Честно предупреждаю: Optique ещё незрелая. В чём-то я сам пока продолжаю разбираться, API может немного гулять. Но основная идея — хороший парсинг заменяет валидацию — никуда не денется. Я уже несколько месяцев вообще не пишу валидационного кода.

Это по-прежнему ощущается странно. В хорошем смысле.

Пробовать или нет - дело ваше

Если вас заинтересовал пост, то вот вам:

Не скажу, что Optique решит все ваши проблемы с CLI. Хочу лишь сказать, что я устал видеть повсюду один и тот же валидационный код, поэтому написал библиотеку, которая меня от этого избавляет.

Решать вам. Кстати, а сейчас вы пишете валидационный код? Может быть, он вам и не понадобится.

Комментарии (0)


  1. MonkAlex
    16.09.2025 12:58

    А что за CLI пишутся на тайпскрипте? Речь про ноду?


  1. VADemon
    16.09.2025 12:58

    The or() combinator means exactly one succeeds.

    Оно должно бы было называться либо "xor", либо по-английски "either".

    but because I was tired of seeing—and writing—the same validation code everywhere.

    Все уже придумано до вас. Питоновский argparse (и порты) - парсилка здорового человека. Остальное ложится на пользователя:

    Но использование и интеграция в систему типов TS выглядит удобной.

    PS: По-другому можно выразиться, что надо проверять рано и fail-fast. До ухода в логику программы проверять опции, заполнять нужные поля. Таким образом бизнес-логике остается только пользоваться геттерами. Валидация логики по всей программе -- это как болезнь распространяющая всюду. Вместо этого иметь строгий "вход" данных внутрь периметра и единообразный "вывод" наружу.


    1. IUIUIUIUIUIUIUI
      16.09.2025 12:58

      Парсилка здорового человека — это optparse-applicative и, для ленивых, optparse-generic.

      Как раз, кстати, парсится в по построению валидную структуру данных.