Введение

Нередко в проектах необходимо заводить переменные окружения (environment variables). Существует много способов сделать это. Например, указать переменную "inline", как MY_VAR="my value" node index.js или обозначить источник командой source. Некоторые фреймворки имеют даже целые отдельные пакеты для формирования переменных окружения (прим. nest.js). Но чаще всего за годы работы в сфере фронтенд-разработки мне приходилось работать со способом, который подразумевает содержание .env файлов в проекте. Такие файлы имеют простейший синтаксис вида KEY=VALUE:

# .env

APP_TITLE="My application"
PORT=3000

Для чего вообще нужны эти переменные и какого рода данные в них стоит записывать? Ответ:

  • какие-либо секретные значения, которые не стоит держать в репозитории.
    Пример: KEY_SECRET от стороннего API или данные для аутентификации БД;

  • значения, которые различаются для разных режимов работы приложения. Пример: BASE_URL для:

    • dev-стенда — https://dev.my-amazing-resource.com

    • test-стенда — https://stage.my-amazing-resource.com

    • production — https://my-amazing-resource.com

  • параметры конфигурации или глобальные константы. Пример: PORT, PROXY_URL, FEATURE_TOGGLE и т.д.

Таким образом, данные скрыты от чужих глаз и могут конфигурировать наше приложение в (и вне) зависимости от режима его работы. Удобно!

Проблема

А проблема в том, что использование env-переменных не гарантирует нам наличие значения и не дает понимание о его типе данных. Взгляните на следующий конфиг:

# .env

APP_TITLE="My app"
APP_VERSION=1
SHOW_VERSION=true
# COMMENTED_REQUIRED_VALUE=

Во-первых, может показаться, что APP_TITLE, APP_VERSION и SHOW_VERSION — это строка, число и логическое значение соответственно. На самом же деле ВСЕ эти значения являются строками.

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

Что мы имеем по итогу:

  1. Для того, чтобы работать со значениями согласно задуманным им типам данных, необходимо сначала привести их к этим типам:

    • const title = process.env.APP_TITLE; // all is ok

    • const version = Number(process.env.APP_VERSION);

    • const showVersion = JSON.parse(process.env.SHOW_VERSION);


    На этом этапе приложение может сломать самая малая опечатка в конфиге, например, False вместо false или 1,0 вместо 1. Стоит подумать на счет разнообразных проверок, обработки ошибки парсинга и прочем.

  2. Так как .env не дает никакой валидации данных, мы, вызывая переменную COMMENTED_REQUIRED_VALUE, которая считается обязательной и всегда должна быть прописана, НО почему-то осталась закомментированной, получаем ошибку. Окей, просто добавить еще проверок наличия обязательного значения, приведения типов, проверок типов.

Добавили все необходимые приведения типов, проверки, и теперь код...

Нет.

Решение v0

Наверняка, вы уже самостоятельно додумали, что было дальше. Такое решение не очень-то красиво и удобно. Особенно, учитывая гораздо большее количество переменных, появление новых и другие аспекты. В этом разделе я хочу представить ???? хорошее ???? решение и более подробно разобрать сам подход, а в следующем разделе поговорим о его "доведенной до ума" версии — ✨ отличном ✨ решении.

За основу будет взят проект, созданный Vite по шаблону react-swc-ts, что значит React + SWC + TypeScript. На самом деле, описываемый подход не зависит от конкретно этих инструментов и может быть применен практически к любому стеку.

Установил зависимости, поставил prettier, настроил линтинг, отформатировал код, поставил @types/node, поправил package.json, поправил vite.config.ts. Теперь хорошо. Я не буду углубляться в структуру проекта, рассказывать какие-то подробности Vite или React, если они НЕ ИМЕЮТ отношения к делу. Только суть.

yarn dev
# или
vite
Такую страницу получаем при запуске приложения
Такую страницу получаем при запуске приложения

Чтобы избавиться от "лишнего шума" я удалю весь код, сгенерированный Vite и оставлю только один заголовок h1, который послужит для визуализации значений.

Итак, я хочу иметь в приложении конфиг, описанный выше в качестве примера, так что я создам в корне проекта следующий .env файл:

APP_TITLE="My app"
APP_VERSION=1
SHOW_VERSION=true
# COMMENTED_REQUIRED_VALUE=

Vite предоставляет немного своеобразный способ работы с переменными окружения, в частности с .env. Подробнее об этом можно прочитать в официальной документации, но пока что следует знать только то, что все переменные должны быть указаны с преифксом VITE_, а вызов этих переменных внутри приложения происходит через специальный объект import.meta.env.
Таким образом, получаем конфиг такого вида:

VITE_APP_TITLE="My app"
VITE_APP_VERSION=1
VITE_SHOW_VERSION=true
# VITE_COMMENTED_REQUIRED_VALUE=

И в приложении попробуем использовать переменную VITE_APP_TITLE, просто поместив ее внутрь заголовка h1, пользуясь интерполяцией JSX(TSX):

export const App = () => {
  return <h1>{import.meta.env.VITE_APP_TITLE}</h1>;
};
Результат
Результат

Отлично! Это работает. Но если посмотрим в коде на тот самый специальный объект import.meta.env, то увидим, что он вроде как и не содержит нужных нам свойств. Более того, любое его свойство, помимо встроенных BASE_URL, MODE, DEV, PROD, SSR, является значением типа any.

Посмотрим на решение, которое предоставляет Vite. Необходимо описать модуль env.d.ts, предварительно добавив его в директорию src или куда вам угодно, например, в src/types:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string;
  readonly VITE_APP_VERSION: number;
  readonly VITE_SHOW_VERSION: boolean;
  readonly VITE_COMMENTED_REQUIRED_VALUE: any;
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

Выглядит неплохо, теперь мы можем просматривать все переменные окружения, которые описали в модуле и опираться на их типы данных.

Но вопрос с валидацией данных остается открытым. IDE подсказывает, что переменная VITE_APP_VERSION является значением типа number. Но по факту, проверив значение через typeof, мы получаем string:

Такое решение может, скорее, сбить столку. Поэтому приступим к написанию собственного. Для начала добавим следующую структуру файлов: в директории src создаем новую директорию config, в которую помещаем два .ts документа:

  • index.ts

  • buildEnvProxy.ts

buildEnvProxy.ts будет содержать в себе и экспортировать одноименную функцию, которая принимает на вход следующие параметры: source — источник, из которого берутся переменные окружения (как вы помните, в нашем случае это import.meta.env) и необязательный transformKey — функция, которая поможет преобразовать ключи (названия переменных) конфига, если это потребуется.

С источником понятно, но зачем преобразовывать ключи? Дело в том, что мне бы не хотелось постоянно использовать префикс VITE_, поэтому через Proxy я буду подставлять его автоматически.

Функция возвращает этот самый Proxy, который имеет только один handler — get. Его и опишем.

Так как ключ в объекте — это string | symbol, а наш конфиг, по идее — это объект типа Record<string | unknown>, где ключ — это string, то нужно сначала привести этот ключ к строке с помощью String.

Когда ключ в строковом формате имеется, нужно провести проверку на наличие функции transformKey и, если она существует — выполнить преобразование. В конечном итоге get возвращает значение из source по ключу, который был приведен к строке и преобразован при наличии функции transformKey:

// buildEnvProxy.ts

export const buildEnvProxy = <T extends Record<string, unknown>>(
  source: T,
  transformKey: (key: string) => string,
) =>
  new Proxy({} as T, {
    get(_, key) {
      const keyStr = String(key);
      const envKey = transformKey ? transformKey(keyStr) : keyStr;

      return source[envKey];
    },
  });

Внутри index.ts объявляем константу ENV и присваиваем ей результат выполнения описанной выше функции, указав в качестве generic Record<string, unknown>, а в качестве параметров: источник — import.meta.env и функцию преобразователь, которая подставляет к ключу корректный префикс:

// index.ts

import { buildEnvProxy } from './buildEnvProxy.ts';

const ENV = buildEnvProxy<Record<string, unknown>>(
  import.meta.env,
  (key) => `VITE_${key}`,
);

Проверяем:

console.log('Without prefix:', ENV.APP_TITLE);
console.log('With prefix', ENV.VITE_APP_TITLE);

Получаем результат:

Теперь приступим непосредственно к типизации конфига. Первым делом в этой же директории создаем документ config.types.ts, в котором описываем и экспортируем type Config с типами данных значений:

// config.types.ts

export type Config = {
  APP_TITLE: string;
  APP_VERSION: number;
  SHOW_VERSION: boolean;
  COMMENTED_REQUIRED_VALUE: any;
};

Вот на этом моменте получается что-то похожее на решение, предоставленное Vite, т.к. типизация под конфиг имеется, но значения никак не преобразуются и по прежнему остаются типа string.

Для дальнейшего преобразования, внутри директории config заводим новую директорию configGetters, а в ней три документа, для начала:

  • getBoolean.ts

  • getNumber.ts

  • getString.ts

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

// getBoolean.ts

export const getBoolean = (
  target: Record<string, unknown>,
  key: string,
): boolean => {
  const value = target[key];

  try {
    if (value === 'true' || value === true) return true;
    if (value === 'false' || value === false) return false;

    throw new Error();
  } catch {
    throw new Error(`Config value for "${key}" is not a boolean: "${value}"`);
  }
};
// getNumber.ts

export const getNumber = (
  target: Record<string, unknown>,
  key: string,
): number => {
  const value = target[key];

  try {
    const numValue = Number(value);

    if (isFinite(numValue) && !isNaN(numValue)) return numValue;

    throw new Error();
  } catch {
    throw new Error(`Config value for "${key}" is not a number: "${value}"`);
  }
};
// getString.ts

export const getString = (
  target: Record<string, unknown>,
  key: string,
): string => {
  const strValue = target[key];

  try {
    if (typeof strValue === 'string') return strValue;

    throw new Error();
  } catch {
    throw new Error(`Config value for "${key}" is not a string: "${strValue}"`);
  }
};

Если значение из конфига не соответствует ожиданиям — будет выброшена ошибка.

Помимо создания функций, необходимо также указать, какая из функций соответствует конкретной переменной. Для этого поднимемся на уровень выше, т.е. вернемся в директорию config и создадим еще один документ — config.gettersMap.ts, в котором привяжем функции к переменным:

// config.gettersMap.ts

import type { Config } from './config.types.ts';

import { getString } from './configGetters/getString.ts';
import { getNumber } from './configGetters/getNumber.ts';
import { getBoolean } from './configGetters/getBoolean.ts';

export const CONFIG_GETTERS_MAP: {
  [K in keyof Config]: (target: Record<string, unknown>, key: K) => Config[K];
} = {
  APP_TITLE: getString,
  APP_VERSION: getNumber,
  SHOW_VERSION: getBoolean,
  COMMENTED_REQUIRED_VALUE: getString
};

Создается и экспортируется объект, обязательными ключами которого являются ключи типа Config, т.е. наши переменные окружения. И каждому ключу присваивается соответствующая функция.

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

Заключительной частью данной секции является сбор всего того, что мы написали вместе. Для этого на том же уровне создаем еще один, последний документ — buildConfigProxy.ts. Документ содержит одноименную функцию, которая принимает:

  1. generic для того, чтобы обозначить тип возвращаемого значения;

  2. два параметра:

    • env — источник переменных окружения, который мы получили ранее с помощью функции buildEnvProxy

    • envGettersMap — наш объект с присвоенными функциями преобразования — CONFIG_GETTERS_MAP

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

// buildConfigProxy.ts

export const buildConfigProxy = <T extends Record<string, unknown>>({
  env,
  envGettersMap,
}: {
  env: Record<string, unknown>;
  envGettersMap: {
    [K in keyof T]: (target: Record<string, unknown>, key: K) => T[K];
  };
}) =>
  new Proxy({} as T, {
    get(_, key) {
      const keyStr = String(key);
      const getter = envGettersMap[keyStr];

      if (!getter || typeof getter !== 'function') {
        throw new Error(`Config: Proxy getter for "${keyStr}" is not defined`);
      }

      return getter(env, keyStr);
    },
  });

Остается только дополнить index.ts вызовом данной функции:

// index.ts

import { buildEnvProxy } from './buildEnvProxy.ts';
import { buildConfigProxy } from './buildConfigProxy.ts';
import { CONFIG_GETTERS_MAP } from './config.gettersMap.ts';

import type { Config } from './config.types.ts';

const ENV = buildEnvProxy<Record<string, unknown>>(
  import.meta.env,
  (key) => `VITE_${key}`,
);

export const CONFIG = buildConfigProxy<Config>({
  env: ENV,
  envGettersMap: CONFIG_GETTERS_MAP,
});

В качестве generic указали тип Config, о чем уже подсказывает IDE:

Проверяем:

# .env

VITE_APP_TITLE="My app"
VITE_APP_VERSION=1
VITE_SHOW_VERSION=true
# VITE_COMMENTED_REQUIRED_VALUE=
// App.tsx

import { CONFIG } from './config';

export const App = () => 
  return (
    <h1>
      {CONFIG.SHOW_VERSION && ( 
        <>
          {CONFIG.APP_TITLE} v{CONFIG.APP_VERSION.toFixed(1)}
        </>
      )}
    </h1>
  );
};

Получаем результат:

Как видите, все значения были получены и код отработал корректно. Об этом свидетельствует, как отображение заголовка h1, так и выполненный над переменной CONFIG.APP_VERSION метод .toFixed(1), присущий типу number.

Теперь в .env закомментируем любую вызываемую переменную, например, VITE_APP_TITLE:

# VITE_APP_TITLE="My app"
VITE_APP_VERSION=1
VITE_SHOW_VERSION=true
# VITE_COMMENTED_REQUIRED_VALUE=

Получаем результат в качестве результата ошибку Config value for "APP_TITLE" is not a string: "undefined" и сразу понимаем, что стоит поправить в конфиге:

И для завершения раскомментируем переменную VITE_APP_TITLE и изменим теперь уже значение переменной VITE_APP_VERSION:

VITE_APP_TITLE="My app"
VITE_APP_VERSION=1,0
VITE_SHOW_VERSION=true
# VITE_COMMENTED_REQUIRED_VALUE=

Результат — ошибка Config value for "APP_VERSION" is not a number: "1,0":

Круто! Мы проделали хорошую работу: конфиг теперь типизирован и свалидирован. Помимо, простейших преобразований и проверок, таких как getNumber, getString и getBoolean, вы можете сделать любые другие. Например, enum:

export const getEnum =
  <T>(
    getter: (target: Record<string, unknown>, key: string) => unknown,
    allowedValues: T[],
  ): ((target: Record<string, unknown>, key: string) => T) =>
  (target, key) => {
    const value = getter(target, key) as T;

    try {
      if (allowedValues.includes(value)) return value;

      throw new Error();
    } catch {
      throw new Error(
        `Config value for "${key}" is not one of allowed values [${allowedValues}]: "${value}"`,
      );
    }
  };

Такая функция принимает на вход другую функцию геттер и список допустимых значений, а также имеет generic с указанием этих значений. Вот пример использования:

...
MODE: getSpecific<'production' | 'development' | 'test'>(getString, [
  'production',
  'development',
  'test',
]),
...

На этом секция про ???? хорошее ???? решение подошла к концу. Вот такая структура получилась в итоге:

Но что же с VITE_COMMENTED_REQUIRED_VALUE? Эта переменная как раз напрямую связана со вторым, ✨ отличным ✨ способом решения проблемы.

Решение v1

Итак, что если в конфиге необходимо обозначить объект?

VITE_COMMENTED_REQUIRED_VALUE как раз подразумевалась, как такая переменная.

Можно написать еще функций преобразования, подобных getNumber, getBoolean и т.д., но если таких переменных будет много и они все будут содержать объекты разных структур, то это уже не кажется хорошей идеей.

Здесь на помощь приходит Zod.

На официальном сайте сказано следующее:

Zod — это TypeScript-first библиотека объявления и проверки схем. Я использую термин «схема» для широкого обозначения любого типа данных от простого string к сложному вложенному объекту.

Проще говоря, мы можем создать какую-либо схему, а затем по этой схеме валидировать значения. Пример (тоже с оф. сайта):

import { z } from "zod";

// создание схемы для строк (string)
const mySchema = z.string();

// парсинг
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError

// "безопасный" парсинг (не throw error валидация провалилась)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }

И еще немного значимых плюсов (опять же указанных на оф. сайте):

  • 0 зависимостей;

  • работает в Node.js и всех современных браузерах;

  • крошечный: 8kb minified + zipped;

  • работает с JS (TS необязателен) — в данном случае неактуально, но круто!

Приступим к внедрению Zod. Первым делом устанавливаем библиотеку, я сделаю это с помощью yarn:

yarn add zod

А затем удаляем из директории config ВСЕ, что мы написали в секции "Решение v0", кроме index.ts и buildEnvProxy.ts. Да, нам больше не требуются функции преобразования, buildConfigProxy, CONFIG_GETTERS_MAPZod все сделает за нас! (почти)

Создаем в директории новый документ — config.schema.ts. В этом документе опишем схему конфига и экспортируем функцию, которая будет принимать на вход наш ENV, парсить его и в случае успешного парсинга возвращать валидный объект конфига. В случае неудачного парсинга из-за неправильно указанного (или вовсе неуказанного) значения переменной окружения, функция будет выбрасывать ошибку.

Так как конфиг — это объект, обозначим схему, как z.object({}), а внутри, в фигурных скобках опишем этот объект с помощью других методов Zod'а:

// config.schema.ts

import { z } from 'zod';

const configSchema = z.object({
  APP_TITLE: z.string(),
  APP_VERSION: z.number(),
  SHOW_VERSION: z.boolean(),
});

Ниже экспортируем ту самую функцию, которая будет парсить конфиг:

export const parseConfig = (configObj: Record<string, unknown>) => {
  const parseResult = configSchema.safeParse(configObj);

  if (!parseResult.success) throw parseResult.error;

  return parseResult.data;
}

Внутри вызываем у схемы метод safeParse, в который передаем объект конфига. Результатом выполнения метода будет некий объект, который всегда содержит логическое свойство success, а также data в случае успеха и error в случае неудачи. Мы используем все эти свойства и в случае success возвращаем data, а в случае неудачи выбрасываем error.

Далее идем в index.ts и убираем все лишнее, то есть несуществующие импорты (потому что ранее мы удалили экспорты) и значение экспортируемой константы CONFIG, мы его перепишем:

import { buildEnvProxy } from './buildEnvProxy.ts';
import { parseConfig } from './config.schema.ts';

const ENV = buildEnvProxy<Record<string, unknown>>(
  import.meta.env,
  (key) => `VITE_${key}`,
);

export const CONFIG = parseConfig(ENV);

export type Config = typeof CONFIG;

Теперь в константу CONFIG будет записан полностью валидный объект со всеми необходимыми переменными окружения, а если возникнет ошибка, Zod скажет об этом еще до отрисовки нашей страницы. Проверяем:

И сразу получаем ошибку. А также обратите внимание на белую страницу, она действительно даже не отрисовалась! В ошибке говорится о том, что переменные APP_VERSION и SHOW_VERSION должны быть number и boolean соответственно, но приходит string. Так и есть, как помните все указанные значения в .env являются строками. Следовательно, необходимо выполнить преобразование этих строк, и Zod имеет несколько способов сделать это. Я воспользуюсь способом "принуждение" — .coerce, потому что пока что мы имеем дело с примитивами (сейчас объясню):

const configSchema = z.object({
  APP_TITLE: z.string(),
  APP_VERSION: z.coerce.number(),
  SHOW_VERSION: z.coerce.boolean(),
});

В данной схеме как бы говорится "возьми переменную APP_VERSION и приведи ее к типу number". Аналогично и с SHOW_VERSION. Если приведение к указанному типу и последующая валидация полученного значения проходит неудачно — получаем ошибку. Проверяем с правильно указанными значениями:

Все работает отлично. А теперь исправляем APP_VERSION на 1,0 и получаем ошибку о том, что ожидается значение типа number, но получается NaN (да, я знаю, но вот так — хорошо же!). Если закомментировать в конфиге какую-либо из описанных в схеме переменных — также получим ошибку, но о том, что вместо ожидаемого значения пришло undefined.

Все работает, как надо и Zod уже закрыл все изначальные потребности. Наконец займемся переменной COMMENTED_REQUIRED_VALUE. Определимся, что данная переменная должна содержать массив простых объектов, что-то вроде:

Array<{
  solution: string;
  rate: 'A' | 'B';
}>

Сразу становится понятно, что такой массив необходимо передавать в формате JSON, поэтому опишем переменную в конфиге следующим образом:

VITE_APP_TITLE="My app"
VITE_APP_VERSION=1
VITE_SHOW_VERSION=true
VITE_COMMENTED_REQUIRED_VALUE='[{"solution":"v1","rate":"A"},{"solution":"v0","rate":"B"}]'

И приступим к описанию данного значения в схеме. Это будет выглядеть, как цепочка проверок и преобразований.

Абсолютно точно, изначально значение является строкой, поэтому необходимо указать метод .string(). Когда получаем валидную строку, нужно проверить, что она также является валидным JSON-форматом. У Zod нет решения "из коробки" конкретно для JSON, но зато есть метод .transform(), который принимает в качестве параметра функцию преобразователь. Эта функция в свою очередь в качестве параметра имеет наше дошедшее к этому моменту значение — просто валидную строку. Эту строку нужно распарсить, как JSON — это и есть проверка и преобразование. Получается .transform((value) => JSON.parse(value)). А дальше все, как и в случае со схемой самого конфига: .object(...) внутри которого описываем свойства solution и rate. Но для того, чтобы сделать проверку над преобразованным методом .transform() значением, необходимо обернуть его методом .pipe(), то есть обозначить "пайплайн" проверки.

И единственное отличие в том, что данный преобразованный объект все таки является массивом. Для этого указания просто добавляем после метода .object() метод .array(): .object(...).array(). Посмотрим, что получилось:

...
COMMENTED_REQUIRED_VALUE: z
    .string() // Проверяем строку
    .transform((val) => JSON.parse(val)) // Преобразуем в JSON
    .pipe( // Указываем "пайплайн"
      z
        .object({ // Проверяем объект
          solution: z.string(),
          rate: z.enum(['A', 'B']),
        })
        .array(), // Проверяем, что это именно массив объектов
    ),
...

В коде использовался еще один метод, который не упоминался ранее — .enum(). С помощью данного метода можно строго задать значения, которые могут быть приняты на вход. Кстати, помимо таких "enum'ов", Zod умеет также работать с нативными enum.

В конце концов слегка дополним компонент и посмотрим, что получилось:

import { CONFIG } from './config';

export const App = () => {
  return (
    <>
      <h1>
        {CONFIG.SHOW_VERSION && (
          <>
            {CONFIG.APP_TITLE} v{CONFIG.APP_VERSION.toFixed(1)}
          </>
        )}
      </h1>
      {CONFIG.COMMENTED_REQUIRED_VALUE.map(({ solution, rate }) => (
        <div>
          Solution {solution} — {rate}
        </div>
      ))}
    </>
  );
};

Результат:

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

Большим плюсом является то, что данное решение подходит для множества окружений: если проект Vite — используем функцию buildEnvProxy, куда передаем источник и transformKey, который подставляет префикс _VITE; если вдруг проект переводится на Webpack, то достаточно просто передать в функцию новый источник и убрать transformKey. А вот так можно улучшить эту функцию:

export const buildEnvProxy = <T extends Record<string, unknown>>(
  envSources: {
    source: Partial<T>;
    transformKey?: (key: string) => string;
  }[],
) =>
  new Proxy({} as T, {
    get(_, key) {
      return envSources
        .map(({ source, transformKey }) => {
          const keyStr = String(key);
          const envKey = transformKey ? transformKey(keyStr) : keyStr;

          return source[envKey];
        })
        .find((v) => v !== undefined);
    },
  });

Теперь мы можем передавать сразу несколько источников и указывать или не указывать transformKey для каждого из них. Handler get будет искать запрашиваемую переменную последовательно во всех источниках, а порядок источников можно менять и тем самым настраивать приоритет.

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

Здесь гитхаб репозиторий с каждым решением (двумя) в отдельной ветке: https://github.com/bodasooqa/vite-react-config-validation

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


  1. Kalexey89
    30.06.2023 10:37
    +1

    В readme у Zod есть раздел "Ecosystem", там можнон найти неплохой пакет для валидации ENV - znv. Да и в целом много интересных пакетов :)


  1. DmitryKazakov8
    30.06.2023 10:37

    Как правило, в проекте создается не только .env, но еще и example.dev.env и example.prod.env. Также нужно проверить, что в этих файлах есть все параметры, которые прописаны в ts-типах. Иначе будет частой ситуация, когда добавляется локальный ключ в .env, а в примерах не добавлен, и внезапно на компе другого разработчика проект не запустится (реальная и ооочень частая история). Вот пример, как можно это дело решить https://github.com/dkazakov8/dk-framework/tree/master/packages/compare-env

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

    class Env {
      constructor(params: Record<keyof Env, unknown>) {
        Object.entries(params).forEach(([envKey, envValue]) => {
          const paramType = typeof this[envKey];
    
          if (paramType === 'boolean') {
            this[envKey] = envValue === true || envValue === 'true';
          } else if (paramType === 'string') {
            this[envKey] = (envValue || '').replace(/"/g, '').trim();
          } else if (paramType === 'number') {
            this[envKey] = Number(envValue || 0);
          }
        });
      }
    
      SENTRY_URL = '';
      HOT_RELOAD = false;
      HOT_RELOAD_PORT = 0;
    }
    
    export const env = new Env(process.env as unknown as Env);
    
    // another file
    import { env } from '../env';
    
    env.SENTRY_URL // perfectly typed

    Как параметры попадают из .env в process.env - это на усмотрение разработчика, самое простое решение - импортнуть dotenv. Также в моем решении предусмотрены типобезопасные дефолтные значения на случай, если в .env не выставлено значение типа "SENTRY_URL=". Но в отличие от статьи нет жестких проверок на типы и выдачи соответствующих ошибок если значение в .env указано не того типа - в целом фича хорошая, но малополезная, пока не встречал случая, чтобы кто-то смог сделать такую ошибку.

    Спасибо за статью, возможно мои решения позволят в чем-то улучшить и ваш подход)


  1. Mingun
    30.06.2023 10:37

    Скажите, а зачем такие странные сложные функции в «хорошем» решении: зачем там выбрасывается Error без параметров, который тут же перехватывается и выбрасывается уже «правильный» Error с параметром? Почему сразу «правильный» не выбрасывается? Overengineering ради overengineering'а?


  1. noodles
    30.06.2023 10:37

    COMMENTED_REQUIRED_VALUE: z
        .string() // Проверяем строку
        .transform((val) => JSON.parse(val)) // Преобразуем в JSON

    скорее, пробуем преобразовать в js-объект из возможной json-строки