Zod: Типизация и валидация Вашего .env (Vite + React и не только)

Введение

Нередко в проектах необходимо заводить переменные окружения (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


ссылка на оригинал статьи https://habr.com/ru/articles/743798/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *