Есть за мной такой грешок: если какая-то проблема мне долго досаждает, я в конце концов пишу библиотеку, которая её решает. На сей раз такая история возникла с кодом для валидации 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
равен false
, port
буквально не существует. Не 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 может немного гулять. Но основная идея — хороший парсинг заменяет валидацию — никуда не денется. Я уже несколько месяцев вообще не пишу валидационного кода.
Это по-прежнему ощущается странно. В хорошем смысле.
Пробовать или нет - дело ваше
Если вас заинтересовал пост, то вот вам:
Туториал: напишите что-нибудь реальное, проверьте, станет ли оно вас бесить.
Концепции: примитивы, конструкции, модификаторы, парсеры значений, вот это всё.
GitHub: код, темы, возмущения.
Не скажу, что Optique решит все ваши проблемы с CLI. Хочу лишь сказать, что я устал видеть повсюду один и тот же валидационный код, поэтому написал библиотеку, которая меня от этого избавляет.
Решать вам. Кстати, а сейчас вы пишете валидационный код? Может быть, он вам и не понадобится.
Комментарии (0)
VADemon
16.09.2025 12:58The
or()
combinator means exactly one succeeds.Оно должно бы было называться либо "xor", либо по-английски "either".
but because I was tired of seeing—and writing—the same validation code everywhere.
Все уже придумано до вас. Питоновский argparse (и порты) - парсилка здорового человека. Остальное ложится на пользователя:
conflict_handler
argument_default
Но использование и интеграция в систему типов TS выглядит удобной.
PS: По-другому можно выразиться, что надо проверять рано и fail-fast. До ухода в логику программы проверять опции, заполнять нужные поля. Таким образом бизнес-логике остается только пользоваться геттерами. Валидация логики по всей программе -- это как болезнь распространяющая всюду. Вместо этого иметь строгий "вход" данных внутрь периметра и единообразный "вывод" наружу.
IUIUIUIUIUIUIUI
16.09.2025 12:58Парсилка здорового человека — это optparse-applicative и, для ленивых, optparse-generic.
Как раз, кстати, парсится в по построению валидную структуру данных.
MonkAlex
А что за CLI пишутся на тайпскрипте? Речь про ноду?