Введение
Нередко в проектах необходимо заводить переменные окружения (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
, которая неспроста названа именно так. Предположим, что это обязательное значение, без которого приложение не может функционировать.
Что мы имеем по итогу:
-
Для того, чтобы работать со значениями согласно задуманным им типам данных, необходимо сначала привести их к этим типам:
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
. Стоит подумать на счет разнообразных проверок, обработки ошибки парсинга и прочем. Так как .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. Документ содержит одноименную функцию, которая принимает:
generic для того, чтобы обозначить тип возвращаемого значения;
-
два параметра:
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_MAP
— Zod все сделает за нас! (почти)
Создаем в директории новый документ — 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)
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 указано не того типа - в целом фича хорошая, но малополезная, пока не встречал случая, чтобы кто-то смог сделать такую ошибку.
Спасибо за статью, возможно мои решения позволят в чем-то улучшить и ваш подход)
Mingun
30.06.2023 10:37Скажите, а зачем такие странные сложные функции в «хорошем» решении: зачем там выбрасывается
Error
без параметров, который тут же перехватывается и выбрасывается уже «правильный»Error
с параметром? Почему сразу «правильный» не выбрасывается? Overengineering ради overengineering'а?
noodles
30.06.2023 10:37COMMENTED_REQUIRED_VALUE: z .string() // Проверяем строку .transform((val) => JSON.parse(val)) // Преобразуем в JSON
скорее, пробуем преобразовать в js-объект из возможной json-строки
Kalexey89
В readme у Zod есть раздел "Ecosystem", там можнон найти неплохой пакет для валидации ENV - znv. Да и в целом много интересных пакетов :)