7 лет назад я опубликовал в npm свою первую библиотеку — custom‑border‑mixin. Тогда я на неделю пропал, просто чтобы в своё удовольствие написать SCSS‑миксин. Если вам интересно, что весёлого может быть в SCSS, просто посмотрите на хелпер:

@function param-get($parameters, $key) {
  $value: map-get($parameters, $key);
  @if $key == 'side' and $value != 'top' and $value != 'right' and $value != 'bottom' and $value != 'left' {
    @error 'Value #{$value} of property #{$key} must be either top, or right, or bottom, or left, or vertical, or horizontal, or all.';
  }
  @if ($key == 'size' or $key == 'length' or $key == 'gap') and (type-of($value) != number or type-of($value) == number and $value < 0) {
    @error 'Value #{$value} of property #{$key} must be non-negative size number.';
  }
  @if $key == 'color' and type-of($value) != color {
    @error 'Value #{$value} of property #{$key} must be color.';
  }
  @if $key == 'start' and $value != 'origin' and $value != 'center' and $value != 'opposite' {
    @error 'Value #{$value} of property #{$key} must be either origin, center, or opposite.';
  }
  @return $value;
}

Миксин никому конечно не нужен, и никто о нём не просил, но, кажется, он зажёг во мне некую страсть. Думаю, примерно так люди и приходят в опенсорс.

Меня зовут Дмитрий, и в этой статье я расскажу, как наступление агентного программирования повлияло на мой взгляд на дизайн библиотек и публичных API. И как оно на самом деле ничего не изменило в том, что делает библиотеку хорошей.

Я работал в платформенной команде. 5 лет назад завёл свой личный опенсорс‑проект Sury. Уже v11, и я еще не забил. Сейчас я работаю в Envio, где за деньги делаю самый быстрый инструмент для индексации блокчейна (HyperIndex на GitHub).

Как AI изменил дизайн API библиотек?

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

…в тот самый pit of success — да‑да, я знаю, но это не стареет.

Кроме очевидных вещей вроде подсказывающих сообщений об ошибках, вот несколько практик, которые я применил в релизе Sury v11. Sury — это JavaScript‑библиотека схем, дальше все примеры будут на ней.

1. Явное лучше неявного

В Sury нет parse. Я не хотел давать дефолт, который где‑то там кинет исключение, и никто его не обработает. Поэтому есть два имени, и вам придётся выбрать:

S.parseOrThrow(userSchema, data);
// { id: "p_1" }

S.parseAsResult(userSchema, data);
// { success: true, value: { id: "p_1" } }
// { success: false, issues: [{message: "Expected string, received 42", path: ["name"]}]}

Теперь выбор зафиксирован в коде. Агент скорее возьмёт parseAsResult, а если нет, то на ревью другой агент реально увидит, что ошибку никто не обрабатывает.

Сравните с Zod, где короткое имя досталось parse, который кидает ошибку:

userSchema.parse(data); // throws
userSchema.safeParse(data); // returns a result

Если убрать строчку с safeParse, то parse выглядит ровно так же «safe», правда? Ничто в parse не говорит, что может прилететь исключение, а безопасный вариант спрятан за именем, о котором надо знать заранее.

Иронично, но сейчас это важно даже больше, потому что ревью делает AI. У человека хотя бы могло возникнуть нехорошее предчувствие насчёт parse.

Та же история, только хуже

Теперь посмотрите на is/validate. Что‑то такое есть в каждой схема‑либе, и на первый взгляд выглядит совершенно безобидно:

if (is(userSchema, data)) {
  // data ведь User, да?
}

Но если схема что‑то трансформирует, у этого вопроса два ответа. Каждая библиотека выбирает один за вас, и, сюрприз, выбирают они по‑разному:

Хелпер

Что проверяет

Zod

z.validate(schema, data)

Input

Valibot

v.is(schema, data)

Input

ArkType

schema.allows(data)

Input

TypeBox

Value.Check(schema, data)

Input

io‑ts

codec.is(data)

Output

Effect

Schema.is(schema)(data)

Output

Superstruct

is(data, struct)

Output

Yup

schema.isValidSync(data)

сначала конвертирует, проходят оба

Joi

schema.validate(data)

сначала конвертирует, проходят оба

Sury

S.isInput / S.isOutput

тот, который вы выбрали сами

Один и тот же вызов, противоположный смысл, в зависимости от того, что у вас в package.json. А Yup с Joi в тихую конвертируют значение и поэтому говорят «да» обоим.

Этакая русская рулетка. 👀

Вот поэтому S.is не существует:

const priceSchema = S.string.with(S.to, S.number);

S.isInput(priceSchema, "42"); // true
S.isOutput(priceSchema, "42"); // false

2. Любой выбор там, где это неважно

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

S.parseOrThrow(data, userSchema);
S.parseOrThrow(userSchema, data);
S.parseOrThrow(userSchema)(data);

S.isInput(data, userSchema);
S.isInput(userSchema, data);
S.isInput(userSchema)(data);

Все они дают одинаковый результат, так что любой вариант — правильная.

3. Заставить выбрать там, где это важно

Когда кейс можно прочитать по‑разному, я не хочу выбирать дефолт за вас. Я заставляю выбрать:

S.decodeOrThrow(S.env, S.string);
// SuryError: Ambiguous "" for string. Should a blank input be rejected,
// kept, or read as absent? Choose with S.nonEmpty, S.minLength(0),
// or S.optional

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

4. Алиасы под общеизвестное

Как бы мне ни хотелось продавить своё API, агент или человек сначала пробует синтаксис который знаком. Борьба с этим стоит лишней итерации и раздражения, поэтому я просто сделал алиасы:

S.union(["admin", { role: "user" }]); // Идеоматичный Sury код
S.union([S.schema("admin"), S.schema({ role: S.schema("user") })]); // Явный Sury код
S.union([S.literal("admin"), S.object({ role: S.literal("user") })]); // Zod-like API для привычности

// все три: Schema<"admin" | { role: "user" }>

А если какое‑то из написаний вам в своей кодовой базе не нравится, то это правило линтера, а не решение, которое я должен принять за всех:

// eslint.config.js
"no-restricted-syntax": ["error", {
  selector: "CallExpression[callee.object.name='S'][callee.property.name='object']",
  message: "Use S.schema instead of S.object",
}]

Возвращаясь к pit of success

Честно говоря, всё это вообще не про AI. Библиотека, которая сама ведёт пользователя, и для людей всегда была лучше. Просто агенты перестали прощать то, что мы раньше закрывали документацией.

Всё это есть в Sury v11, который уже вышел. А если хотите больше про библиотеки схем и дизайн библиотек, подписывайтесь на меня в X — это сделает мой день 🙏