DEV Community

Nubami SQReder
Nubami SQReder

Posted on

Either-like Monads to automate Feature Toggles

Привет! Я Андрей Поповский — техлид в бигтехе. Руковожу разработкой, оптимизирую процессы, провожу собесы и разбираю сложные штуки так, чтобы их можно было сразу применять в коде. Сегодня покажу, как монадический подход из функционального программирования может помочь автоматизировать фичефлаги.

Если зашло — заглядывай в мой телеграм-канал, там ещё больше лайфхаков для разработчиков


Последние несколько лет я работал по Git Flow, и меня всё устраивало. Много веток, понятная структура, классика жанра. Но с ростом команды и продукта проявилась серьёзная проблема - потерянные коммиты между ветками релизов и внезапный регресс функциональности.

Ситуация немного облегчалась тем, что в BitBucket есть автомерж. Он сам следит, чтобы релизы были вмержены по-порядку и создаёт PR при необходимости. Но после того как BitBucket импортозаместили на Сферу - стало сильно сложнее, т.к. автомержей туда пока не завезли 🤷‍♂️

Когда в очередной раз обнаружилось, что фикс из одного релиза не попал в следующий, я задумался о переходе на Trunk-Based Development (TBD)1 — подход с одной основной веткой и фичефлагами для управления функциональностью.

Главное препятствие для перехода на TBD в нашем случае — длинные циклы разработки и необходимость поддерживать несколько параллельных релизов с разным набором фич. Добавлять фичефлаги на каждую новую функцию технически несложно, но вести их учет и управлять включением/выключением для нужного релиза — задача нетривиальная и скучная. Да, в целом, эта задача у нас ложится на аналитиков и QA, но лучшая работа — та, которую не нужно делать

Disclaimer: На самом деле, после написания этого поста я успел еще подумать и полностью переделать API. Но хочу рассказать о пройденном пути и как развивалось моё видение


Первый заход

Ключевая идея, которая пришла мне в голову - можно включать фичу автоматически, ориентируясь на версию из package.json. Мы почти наверняка знаем в каком релизе вы выводим функционал, поэтому почему-бы не сравнить версии используя semver, и на основе этого выбрать вариант

const logger = releaseOn({
  currentVersion: PACKAGE_VERSION,
  targetVersion: "3.6.0",
  legacy: console,
  release: prettyLogger,
});
Enter fullscreen mode Exit fullscreen mode

Реализация тут прямолинейная:

👆 Развернуть
import semver from "semver";

type ReleaseOnConfig<T, U> = {
  currentVersion: string;
  targetVersion: string;
  legacy: T;
  release: U;
};

function releaseOn<T, U>(params: ReleaseOnConfig<T, U>): T | U {
  if (semver.lt(params.currentVersion, params.targetVersion)) {
    return params.legacy;
  } else {
    return params.release;
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ Автоматическое включение функционала при обновлении версии
✅ Предсказуемое поведение на основе семантического версионирования
✅ Удобно для фичей, которые должны быть целиком включены или выключены в определенной версии

❌ Каждый переключатель нужно указывать отдельно — бойлерплейтно
❌ Потенциальные проблемы с типизацией при использовании разных типов данных для legacy и release
❌ При изменении планов релиза нужно обновлять все переключатели руками 😒

Будем понемногу разбираться с минусами и начнём по порядку


Боремся с бойлерплейтом

Одно место у нас точно постоянное - версия самого приложения. Ни к чему передавать её в каждом вызове. Значит, вынесем в фабрику, которая будет хранить значение в замыкании

const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });

const logger = releaseOn({
  targetVersion: "3.6.0",
  legacy: console,
  release: prettyLogger,
});

const value = releaseOn({
  targetVersion: "3.6.0",
  legacy: oldValue,
  release: newValue,
});

logger.log(value);
Enter fullscreen mode Exit fullscreen mode

Уже немного лучше, но очевидно, что если мы выводим более-менее крупный кусок функционала, то связанных точек, где нужно сделать переключение по одному флагу может быть множество. Штош, фабрика это хорошо, но фабрика фабрик - лучше 🪄

const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });

const toggle = releaseOn("3.6.0");

const logger = toggle({ legacy: console, release: prettyLogger });
const value = toggle({ legacy: oldValue, release: newValue });

logger.log(value);
Enter fullscreen mode Exit fullscreen mode

Реализация опять-же не должна вызвать затруднений:

👆 Развернуть
type ReleaseToggleConfig = { currentVersion: string };

const makeReleaseToggle = (config: ReleaseToggleConfig) => {
  const { currentVersion } = config;

  return (targetVersion: string) => {
    const shouldUseRelease = semver.gte(currentVersion, targetVersion);

    return <L, R>(options: { legacy: L; release: R }): L | R => {
      return shouldUseRelease ? options.release : options.legacy;
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

✅ Меньше бойлерплейта
✅ Централизованное управление переключениями для связанных компонентов через общий toggle

❌ Нет возможности легко тестировать варианты без изменения версии
❌ Отсутствует понимание того, какие флаги активны в текущей сборке
❌ Остается риск "мертвого" кода, если забыть удалить legacy-логику после релиза и стабилизации
❌ Проблемы с типизацией при использовании разных типов данных для legacy и release


Устраняем неочевидности

Решим первые три проблемы разом, добавив:

  1. Описательные метаданные — каждый переключатель будет содержать описание
  2. Автоматическое логирование — при создании переключателя его состояние фиксируется в консоли
  3. Механизм принудительного переключения — через явное указание состояния флага, вместо версии для активации
  4. Умные предупреждения — система автоматически уведомит нас об устаревших фичефлагах

Вот как это будет выглядеть при использовании:

const releaseOn = makeReleaseToggle({ currentVersion: "3.7.0" });

// logs `'TASK-1234 For 3.8.0' not yet released`
const toggle = releaseOn({
  targetVersion: "3.8.0",
  description: "TASK-1234 For 3.8.0",
});

// logs `Use release version for 'TASK-2345 For 3.7.0'`
const toggle = releaseOn({
  targetVersion: "3.7.0",
  description: "TASK-2345 For 3.7.0",
});

// logs `Use release version for 'TASK-0123 For 3.6.0'`
// warns `Feature toggle target for 'TASK-0123 For 3.6.0' is less than current version. Consider to remove toggle`
const toggle = releaseOn({
  targetVersion: "3.6.0",
  description: "TASK-0123 For 3.6.0",
});

declare const FF_OVERRIDE_VARIANT: 'legacy' | 'release';

const toggle = releaseOn({
  overideVariant: FF_OVERRIDE_VARIANT,
  targetVersion: "3.6.0",
  description: "TASK-0123 For 3.6.0, but may be overrided in runtime",
});
Enter fullscreen mode Exit fullscreen mode

Реализация чуть более замороченная:

👆 Развернуть
type ReleaseToggleConfig = { currentVersion: string };
type ToggleOptions = {
  targetVersion: string;
  description: string;
  overrideVariant?: "legacy" | "release";
};

// Improved implementation with logging capabilities
const makeReleaseToggle = (config: ReleaseToggleConfig) => {
  const { currentVersion } = config;

  return (options: ToggleOptions) => {
    const { targetVersion, description, overrideVariant } = options;

    // Determine if the feature should be released based on version comparison
    const versionDiff = semver.compare(targetVersion, currentVersion);
    const isReleased = versionDiff <= 0;

    // Log appropriate messages based on release status
    if (overrideVariant) {
      console.log(`[TOGGLE] Override to '${overrideVariant}' version for '${description}'`);
    } else if (isReleased) {
      console.log(`[TOGGLE] Use release version for '${description}'`);

      if (versionDiff < 0) {
        console.warn(
          `[TOGGLE] Feature toggle target for '${description}' is less than current version. Consider removing toggle`
        );
      }
    } else {
      console.log(`[TOGGLE] '${description}' not yet released`);
    }

    // Return the toggle function that selects between legacy and release implementations
    return <L, R>(options: { legacy: L; release: R }): L | R => {
      if (overrideVariant === "legacy") return options.legacy;
      if (overrideVariant === "release") return options.release;

      return isReleased ? options.release : options.legacy;
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

Здорово? Офигенно!

Настало время заняться типизацией!


Управляемся с типами на выходе: знакомимся с монадами

Наконец-то мы добрались до монад! Я давно пользуюсь FP-подходами, но долгое время не мог врубиться что такое эти ваши монады – всё объяснялось либо чересчур абстрактно, либо примитивно до бессмысленности. Недавно у меня наконец-то щёлкнуло, и я целый пост об этом написал.

Что такое монада простыми словами?

Монада — это контейнер для значения с дополнительным контекстом. По сути, это просто объект, который:

  1. Умеет заворачивать обычное значение в себя (метод of)
  2. Позволяет применять к внутреннему значению функции, не разворачивая его каждый раз вручную (метод chain)

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

  • Maybe — значение, которое может отсутствовать
  • Either — значение, которое может быть либо корректным, либо ошибкой
  • Promise — не совсем монада, но очень похоже (и вы с ним уже точно работали)

Весь сок именно в Either — он выглядит идеальным для нашей задачи переключения между двумя реализациями!


Either - выглядит очень интересно!

Either позволяет очень удобно выбирать из двух вариантов. Но есть нюанс — у нас нет правильного и неправильного варианта.

В Either у нас Left/Right, а мы назовём Legacy/Release. Оммаж, так сказать.

type Legacy<L> = { legacy: L; release?: never };
type Release<R> = { legacy?: never; release: R };

type Either<L, R> = NonNullable<Legacy<L> | Release<R>>;
Enter fullscreen mode Exit fullscreen mode

Конструкция field?: never тут для следующего:

  • помогает указать, что оба типа имеют одинаковые поля, благодаря чему получается аккуратное пересечение типов
  • never явно указывает, что никакого значения в поле быть не может
  • ?: показывает, что поле опциональное и может пропускаться при объявлении соответствующего объекта

Добавим к этому делу пару хелперов:

// быстрое создание новых значений
const makeLegacy = <L>(value: L): Legacy<L> => ({ legacy: value });
const makeRelease = <R>(value: R): Release<R> => ({ release: value });

// type guards для проверки какое значение перед нами
function isLegacy<L, R>(either: Either<L, R>): either is Legacy<L> {
  return "legacy" in either && either.legacy !== undefined;
}

function isRelease<L, R>(either: Either<L, R>): either is Release<R> {
  return "release" in either && either.release !== undefined;
}

function absurd(value: never): never {
  // Эта функция используется для исчерпывающей проверки типов
  // Она гарантирует, что все возможные случаи обработаны на этапе компиляции
  // Если вызывается во время выполнения, значит мы достигли невозможного состояния
  throw new Error(`Reached impossible state: ${JSON.stringify(value)}`);
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ По поводу того что за утилита absurd можно почитать тут2

Теперь можно гораздо удобнее обходиться с результатом переключения функционала

const logger = toggle({ legacy: console, release: prettyLogger }); // Either<Console, PrettyLogger>

if (isLegacy(logger)) {
  logger.legacy.log(`Good 'ol console`);
} else if (isRelease(logger)) {
  logger.release.log(`Brand new logging possibilities`);
} else {
  // absurd не обязательный, но будет ругаться если мы обработаем не все варианты
  absurd(logger);
}
Enter fullscreen mode Exit fullscreen mode

Откровенно говоря, вот эти logger.legacy и logger.release тоже не прикольные. Поэтому сделаем еще оператор, который будет извлекать из Either нужное значение, да еще и с правильным типом.

const union = unwrap(either); // Console | PrettyLogger

if (isLegacy(either)) {
  const logger = unwrap(either); // Console
} else if (isRelease(either)) {
  const logger = unwrap(either); // PrettyLogger
} else {
  absurd(either);
}
Enter fullscreen mode Exit fullscreen mode

Для красивого вывода типов используем перегрузку функций3:

👆 Развернуть
function unwrap<L>(legacy: Legacy<L>): NonNullable<L>;
function unwrap<R>(release: Release<R>): NonNullable<R>;
function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R>;

function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R> {
  if (either.legacy !== undefined && either.release !== undefined) {
    throw new Error("Received both legacy and release values at runtime when opening an Either");
  } else if (either.legacy !== null) {
    return either.legacy;
  } else if (either.release !== null) {
    return either.release;
  } else {
    throw new Error(`Received no legacy or release values at runtime when opening Either`);
  }
}
Enter fullscreen mode Exit fullscreen mode

В целом удобнее. Но, что если мы хотим как-то обрабатывать или изменять значения в Either и пробрасывать их дальше?

Chaining

Каждый раз писать громоздкую конструкцию из if совсем не хочется.

Помните, что Promise это тоже монада? У Promise есть метод .then(success, failure). В лексиконе FP он может называться map или chain (знатоки, не придирайтесь, пожалуйста). Вот его мы и реализуем.

function map<L, R, T, U>(
  either: Either<L, R>,
  onLegacy: (legacy: NonNullable<L>) => T,
  onRelease: (release: NonNullable<R>) => U
): Either<T, U> {
  if (isLegacy(either)) {
    return makeLegacy(onLegacy(either.legacy));
  } else if (isRelease(either)) {
    return makeRelease(onRelease(either.release));
  } else {
    return absurd(either);
  }
}

// Usage example:
const result = map(
  either,
  (console) => console.log("Using legacy logger"),
  (prettyLogger) => prettyLogger.prettyLog("Using new pretty logger")
);

// Or with more complex transformations:
const tag = "Auth";
const prefixedLogEither = map(
  either,
  (console) =>
    (...values: unknown[]) =>
      console.log(`[${tag}]`, ...values),
  (prettyLogger) => prettyLogger.tagged(tag)
);

const prefixedLog = unwrap(prefixedLogEither);
Enter fullscreen mode Exit fullscreen mode

Промежуточный итог

Итак, у нас есть монады, unwrap, map и другие полезные функции для работы с нашим Either-подобным типом. Подведем итоги и посмотрим, как это все работает на практике.

Полная реализация

Давайте соберем все части воедино:

👆 Развернуть
import semver from "semver";

// Either type definition
type Legacy<L> = { legacy: L; release?: never };
type Release<R> = { legacy?: never; release: R };
type Either<L, R> = NonNullable<Legacy<L> | Release<R>>;

// Either helpers
const makeLegacy = <L>(value: L): Legacy<L> => ({ legacy: value });
const makeRelease = <R>(value: R): Release<R> => ({ release: value });

function isLegacy<L, R>(either: Either<L, R>): either is Legacy<L> {
  return "legacy" in either && either.legacy !== undefined;
}

function isRelease<L, R>(either: Either<L, R>): either is Release<R> {
  return "release" in either && either.release !== undefined;
}

function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R> {
  if (isLegacy(either)) {
    return either.legacy as NonNullable<L>;
  } else if (isRelease(either)) {
    return either.release as NonNullable<R>;
  } else {
    throw new Error(`Received no legacy or release values at runtime when opening Either`);
  }
}

function map<L, R, T, U>(
  either: Either<L, R>,
  onLegacy: (legacy: NonNullable<L>) => T,
  onRelease: (release: NonNullable<R>) => U
): Either<T, U> {
  if (isLegacy(either)) {
    return makeLegacy(onLegacy(either.legacy as NonNullable<L>));
  } else if (isRelease(either)) {
    return makeRelease(onRelease(either.release as NonNullable<R>));
  } else {
    throw new Error("Invalid Either value");
  }
}

// Release toggle implementation
type ReleaseToggleConfig = { currentVersion: string };

type ToggleOptions = {
  targetVersion: string;
  description: string;
  overrideVariant?: "legacy" | "release";
};

const makeReleaseToggle = (config: ReleaseToggleConfig) => {
  const { currentVersion } = config;

  return (options: ToggleOptions) => {
    const { targetVersion, description, overrideVariant } = options;

    // Determine if the feature should be released based on version comparison
    const versionDiff = semver.compare(targetVersion, currentVersion);
    const isReleased = versionDiff <= 0;

    // Log appropriate messages based on release status
    if (overrideVariant) {
      console.log(`[TOGGLE] Override to '${overrideVariant}' version for '${description}'`);
    } else if (isReleased) {
      console.log(`[TOGGLE] Use release version for '${description}'`);

      if (versionDiff < 0) {
        console.warn(
          `[TOGGLE] Feature toggle target for '${description}' is less than current version. Consider removing toggle`
        );
      }
    } else {
      console.log(`[TOGGLE] '${description}' not yet released`);
    }

    // Return the toggle function that creates appropriate Either
    return <L, R>(options: { legacy: L; release: R }): Either<L, R> => {
      const { legacy, release } = options;

      if (overrideVariant === "legacy") return makeLegacy(legacy);
      if (overrideVariant === "release") return makeRelease(release);

      return isReleased ? makeRelease(release) : makeLegacy(legacy);
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

Практическое применение

Рассмотрим несколько сценариев использования нашего решения:

Пример 1: Переключение между реализациями логгера

// Инициализация
const PACKAGE_VERSION = "3.7.0";
const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });

// Создание переключателя для конкретной фичи
const loggerToggle = releaseOn({
  targetVersion: "3.8.0",
  description: "TASK-1234: Enhanced logging system",
});

// Определение старого и нового логгера
const legacyLogger = {
  log: (message: string) => console.log(message),
  error: (message: string) => console.error(message),
};

const newLogger = {
  log: (message: string) => console.log(`[INFO] ${message}`),
  error: (message: string) => console.error(`[ERROR] ${message}`),
  debug: (message: string) => console.log(`[DEBUG] ${message}`),
};

// Использование
const logger = loggerToggle({ legacy: legacyLogger, release: newLogger });

// Вариант 1: Проверка типа и использование
if (isLegacy(logger)) {
  logger.legacy.log("Using legacy logger");
} else {
  logger.release.log("Using new logger");
  logger.release.debug("Debug info available only in new logger");
}

// Вариант 2: Извлечение значения
const actualLogger = unwrap(logger);
actualLogger.log("This works with both implementations");

// Применение операций, сохраняющих Either
const taggedLogger = map(
  logger,
  (legacy) => ({
    log: (message: string) => legacy.log(`[Auth] ${message}`),
    error: (message: string) => legacy.error(`[Auth] ${message}`),
  }),
  (release) => ({
    log: (message: string) => release.log(`[Auth] ${message}`),
    error: (message: string) => release.error(`[Auth] ${message}`),
    debug: (message: string) => release.debug(`[Auth] ${message}`),
  })
);

// ... unwrap(taggedLogger).log('There will be tag')

Enter fullscreen mode Exit fullscreen mode

Пример 2: Переключение компонентов UI

// Определяем переключатель для новой формы регистрации
const registerFormToggle = releaseOn({
  targetVersion: "3.7.0",
  description: "TASK-5678: Improved registration form",
});

// Использование в React-компоненте
function Registration() {
  const formComponent = registerFormToggle({
    legacy: LegacyRegistrationForm,
    release: EnhancedRegistrationForm,
  });

  // Можно просто извлечь компонент
  const FormComponent = unwrap(formComponent);
  return <FormComponent />;

  // Или использовать условный рендеринг с типобезопасностью
  /*
  if (isLegacy(formComponent)) {
    return <formComponent.legacy showLegacyNote={true} />;
  } else {
    return <formComponent.release withNewFields={true} />;
  }
  */
}
Enter fullscreen mode Exit fullscreen mode

Пример 3: Настройка через параметры окружения

// Настраиваем глобальный переключатель с возможностью переопределения
const FF_OVERRIDE = process.env.FEATURE_FLAG_OVERRIDE as "legacy" | "release" | undefined;

const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });

const paymentSystemToggle = releaseOn({
  targetVersion: "3.9.0",
  description: "TASK-9012: New payment processing system",
  overrideVariant: FF_OVERRIDE,
});

// Использование
const paymentSystem = unwrap(
  paymentSystemToggle({
    legacy: legacyPaymentProcessor,
    release: newPaymentProcessor,
  })
);

async function processPayment(amount: number) {
  try {
    return await paymentSystem.process(amount);
  } catch (error) {
    console.error("Payment processing failed:", error);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Преимущества подхода

Типобезопасность — TypeScript помогает отслеживать разницу между версиями реализаций
Автоматизация — переключение происходит на основе версии, без ручного управления
Прозрачность — логирование помогает видеть, какие переключатели активны
Гибкость — можно переопределять поведение через параметры среды
Чистый код — использование монад позволяет писать более декларативный код
Удобство тестирования — можно легко тестировать обе реализации

Недостатки подхода

Увеличение сложности — добавление монад и функциональных конструкций может усложнить код для разработчиков, не знакомых с FP
Накладные расходы — дополнительный слой абстракции может привести к небольшому снижению производительности
Кривая обучения — требуется время, чтобы команда освоила этот подход
Риск забытого кода — несмотря на предупреждения, legacy-код может оставаться в репозитории дольше необходимого
Зависимость от semver — корректная работа системы зависит от правильного версионирования
Дополнительная инфраструктура — необходимо поддерживать систему логирования и мониторинга переключателей

Заключение

Этот подход сочетает:

  • Удобство feature toggles
  • Автоматизацию на основе семантического версионирования
  • Функциональное программирование с Either-like монадой

Это позволяет эффективно управлять функциональностью в условиях trunk-based development с параллельной разработкой нескольких релизов.

В конечном итоге, когда legacy-версия функционала становится ненужной (после нескольких релизов), переключатели можно безопасно удалить, ориентируясь на предупреждения в логах.


Если интересно дальше разбираться в концепциях полезных в повседневной работе — подписывайся на мой телеграм-канал


  1. Trunk Based Development: Introduction (EN)
    Trunk Based Development — кто такой и зачем нужен (RU) 

  2. Exhaustive Switch Statements in Typescript 

  3. Typescript function overloads 

Top comments (0)