From a1f541f6a740e3c7557abbdb7825f46566f343fa Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:36 -0300 Subject: [PATCH 1/9] refactor: declare env variables once with envField helpers --- __tests__/env-fields.test.ts | 113 +++++++++++++++++++++++++++++++++++ env-fields.js | 111 ++++++++++++++++++++++++++++++++++ env.js | 85 +++++++++++++++----------- 3 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 __tests__/env-fields.test.ts create mode 100644 env-fields.js diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts new file mode 100644 index 000000000..7b7b16238 --- /dev/null +++ b/__tests__/env-fields.test.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; + +import { envField } from '../env-fields'; + +describe('envField', () => { + describe('string', () => { + it('returns the value as is', () => { + expect(envField.string().parse('https://example.com')).toBe( + 'https://example.com', + ); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.string().safeParse(undefined).success).toBe(false); + expect(envField.string().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.string({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + expect(schema.parse('value')).toBe('value'); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.string({ default: 'fallback' }); + + expect(schema.parse(undefined)).toBe('fallback'); + expect(schema.parse('')).toBe('fallback'); + expect(schema.parse('value')).toBe('value'); + }); + }); + + describe('number', () => { + it('parses numeric strings', () => { + expect(envField.number().parse('42')).toBe(42); + expect(envField.number().parse('0')).toBe(0); + expect(envField.number().parse('-3.5')).toBe(-3.5); + }); + + it('rejects values that are not numbers', () => { + expect(envField.number().safeParse('abc').success).toBe(false); + expect(envField.number().safeParse(' ').success).toBe(false); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.number().safeParse(undefined).success).toBe(false); + expect(envField.number().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.number({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + expect(schema.safeParse('abc').success).toBe(false); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.number({ default: 4321 }); + + expect(schema.parse(undefined)).toBe(4321); + expect(schema.parse('')).toBe(4321); + expect(schema.parse('8080')).toBe(8080); + }); + }); + + describe('boolean', () => { + it('parses "true" and "false"', () => { + expect(envField.boolean().parse('true')).toBe(true); + expect(envField.boolean().parse('false')).toBe(false); + }); + + it('rejects any other value', () => { + expect(envField.boolean().safeParse('yes').success).toBe(false); + expect(envField.boolean().safeParse('1').success).toBe(false); + expect(envField.boolean().safeParse('TRUE').success).toBe(false); + }); + + it('rejects missing and empty values by default', () => { + expect(envField.boolean().safeParse(undefined).success).toBe(false); + expect(envField.boolean().safeParse('').success).toBe(false); + }); + + it('returns undefined for missing or empty values when optional', () => { + const schema = envField.boolean({ optional: true }); + + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse('')).toBeUndefined(); + }); + + it('returns the default value for missing or empty values', () => { + const schema = envField.boolean({ default: false }); + + expect(schema.parse(undefined)).toBe(false); + expect(schema.parse('')).toBe(false); + expect(schema.parse('true')).toBe(true); + }); + }); + + it('reports missing variables as required when used in an object schema', () => { + const result = z + .object({ API_URL: envField.string(), VAR_NUMBER: envField.number() }) + .safeParse({ API_URL: '', VAR_NUMBER: undefined }); + + expect(result.success).toBe(false); + expect(result.error?.flatten().fieldErrors).toEqual({ + API_URL: ['Required'], + VAR_NUMBER: ['Required'], + }); + }); +}); diff --git a/env-fields.js b/env-fields.js new file mode 100644 index 000000000..8cfb2e456 --- /dev/null +++ b/env-fields.js @@ -0,0 +1,111 @@ +/* + * Helpers to declare environment variables in `env.js`. + * Each `envField` builder returns a zod schema that receives the raw value read from `process.env` + * (always a string or undefined), converts it to the right type and validates it. + * This way every variable is declared only once, with its type, in the schemas defined in `env.js`. + * + * Options: + * - optional: the variable may be missing or empty; its value will be `undefined`. + * - default: value used when the variable is missing or empty. + * + * An empty value (e.g. `API_URL=`) is treated the same as a missing one. + */ +const z = require('zod'); + +/** + * @template {z.ZodTypeAny} T + * @typedef {{ optional?: boolean, default?: z.output }} EnvFieldOptions + */ + +/** + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @typedef {O extends { default: {} } ? z.ZodDefault : O extends { optional: true } ? z.ZodOptional : T} EnvFieldInner + */ + +/** + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @typedef {z.ZodEffects, EnvFieldInner['_output'], unknown>} EnvField + */ + +/** + * Builds an env field schema: raw values are converted with `parse` (empty strings become `undefined`) + * and then validated with `schema`, applying the `optional` and `default` options. + * @template {z.ZodTypeAny} T + * @template {EnvFieldOptions} O + * @param {T} schema + * @param {(value: string) => unknown} parse + * @param {O} options + * @returns {EnvField} the env field schema + */ +function createEnvField(schema, parse, options) { + /** @type {z.ZodTypeAny} */ + let inner = schema; + if (options.default !== undefined) { + inner = schema.default(options.default); + } + else if (options.optional) { + inner = schema.optional(); + } + + return /** @type {EnvField} */ ( + z.preprocess((value) => { + if (value === undefined || value === '') { + return undefined; + } + return typeof value === 'string' ? parse(value) : value; + }, inner) + ); +} + +/** + * Numbers are parsed with `Number`; values that are not numbers (e.g. `abc`) are rejected. + * @param {string} value + */ +const parseNumber = value => (value.trim() === '' ? Number.NaN : Number(value)); + +/** + * Only `true` and `false` are accepted; any other value is rejected. + * @param {string} value + */ +function parseBoolean(value) { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return value; +} + +const envField = { + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + string: options => + createEnvField( + z.string(), + value => value, + /** @type {O} */ (options ?? {}), + ), + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + number: options => + createEnvField(z.number(), parseNumber, /** @type {O} */ (options ?? {})), + /** + * @template {EnvFieldOptions} [O={}] + * @param {O} [options] + */ + boolean: options => + createEnvField( + z.boolean(), + parseBoolean, + /** @type {O} */ (options ?? {}), + ), +}; + +module.exports = { envField }; diff --git a/env.js b/env.js index d396e9671..1dcf571ff 100644 --- a/env.js +++ b/env.js @@ -15,6 +15,8 @@ */ const z = require('zod'); +const { envField } = require('./env-fields'); + const packageJSON = require('./package.json'); const path = require('node:path'); @@ -100,7 +102,7 @@ const withEnvSuffix = (name) => /** * 2nd part: Define your env variables schema - * we use zod to define our env variables schema + * we use zod and the `envField` helpers (see `env-fields.js`) to define our env variables schema * * we split the env variables into two parts: * 1. client: These variables are used in the client-side code (`src` folder). @@ -108,24 +110,16 @@ const withEnvSuffix = (name) => * * Main rules: * 1. If you need your variable on the client-side, you should add it to the client schema; otherwise, you should add it to the buildTime schema. - * 2. Whenever you want to add a new variable, you should add it to the correct schema based on the previous rule, then you should add it to the corresponding object (_clientEnv or _buildTimeEnv). + * 2. Declare variables read from the `.env` file with `envField.string()`, `envField.number()` or `envField.boolean()`. + * Their values are read from `process.env` and converted to the declared type automatically, so you only declare them once. + * 3. Static values defined in this file (such as NAME or BUNDLE_ID) are declared with plain zod schemas and their values are set in `clientStaticValues` or `buildTimeStaticValues`. * - * Note: `z.string()` means that the variable exists and can be an empty string, but not `undefined`. - * If you want to make the variable required, you should use `z.string().min(1)` instead. + * Note: env fields are required by default; an empty value (e.g. `API_URL=`) is treated as missing. + * Pass `{ optional: true }` to allow a missing value, or `{ default: value }` to fall back to a default value. * Read more about zod here: https://zod.dev/?id=strings * */ -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseString = (/** @type {string | undefined} */ value) => - value === '' ? undefined : value; -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseNumber = (/** @type {string | undefined} */ value) => - value ? Number(value) : undefined; -// eslint-disable-next-line no-unused-vars, unused-imports/no-unused-vars -const parseBoolean = (/** @type {string | undefined} */ value) => - value ? value === 'true' : undefined; - const clientEnvSchema = z.object({ APP_ENV: z.enum(['development', 'production', 'qa', 'staging']), NAME: z.string(), @@ -135,49 +129,72 @@ const clientEnvSchema = z.object({ VERSION: z.string(), // ADD YOUR CLIENT ENV VARS HERE - API_URL: z.string(), - VAR_NUMBER: z.number(), - VAR_BOOL: z.boolean(), - TERMS_OF_SERVICE_URL: z.string(), - WEBSITE_URL: z.string(), + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + VAR_BOOL: envField.boolean(), + TERMS_OF_SERVICE_URL: envField.string(), + WEBSITE_URL: envField.string(), }); const buildTimeEnvSchema = z.object({ EXPO_ACCOUNT_OWNER: z.string(), EAS_PROJECT_ID: z.string(), // ADD YOUR BUILD TIME ENV VARS HERE - SECRET_KEY: z.string(), + SECRET_KEY: envField.string(), }); /** * @type {Partial>} */ -const _clientEnv = { +const clientStaticValues = { APP_ENV, NAME, SCHEME, BUNDLE_ID: withEnvSuffix(BUNDLE_ID), PACKAGE: withEnvSuffix(PACKAGE), VERSION: packageJSON.version, - - // ADD YOUR ENV VARS HERE TOO - API_URL: parseString(process.env.API_URL), - VAR_NUMBER: parseNumber(process.env.VAR_NUMBER), - VAR_BOOL: parseBoolean(process.env.VAR_BOOL), - WEBSITE_URL: parseString(process.env.WEBSITE_URL), - TERMS_OF_SERVICE_URL: parseString(process.env.TERMS_OF_SERVICE_URL), }; /** - * @type {Record , unknown>} + * @type {Partial>} */ -const _buildTimeEnv = { +const buildTimeStaticValues = { EXPO_ACCOUNT_OWNER, EAS_PROJECT_ID, - // ADD YOUR ENV VARS HERE TOO - SECRET_KEY: parseString(process.env.SECRET_KEY), }; +/** + * Reads the raw value of every variable declared in the schema from `process.env`, + * then overrides them with the static values defined in this file. + * @param {z.AnyZodObject} schema + * @param {Record} staticValues + * @returns {Record} + */ +const readEnv = (schema, staticValues) => ({ + ...Object.fromEntries( + Object.keys(schema.shape).map((key) => [key, process.env[key]]), + ), + ...staticValues, +}); + +/** + * Parses every variable on its own, keeping `undefined` for the invalid ones instead of failing. + * Used when the env variables don't need to be validated. + * @param {z.AnyZodObject} schema + * @param {Record} values + * @returns {Record} + */ +const parseWithoutValidation = (schema, values) => + Object.fromEntries( + Object.entries(schema.shape).map(([key, fieldSchema]) => { + const parsed = fieldSchema.safeParse(values[key]); + return [key, parsed.success ? parsed.data : undefined]; + }), + ); + +const _clientEnv = readEnv(clientEnvSchema, clientStaticValues); +const _buildTimeEnv = readEnv(buildTimeEnvSchema, buildTimeStaticValues); + /** * 3rd part: Merge and Validate your env variables * We use zod to validate our env variables based on the schema we defined above @@ -237,9 +254,9 @@ if (shouldValidateEnv) { } else { // Don't worry about TypeScript here; if we don't need to validate the env variables is because we aren't using them //@ts-ignore - Env = _wholeEnv; + Env = parseWithoutValidation(wholeEnvSchema, _wholeEnv); //@ts-ignore - ClientEnv = _clientEnv; + ClientEnv = parseWithoutValidation(clientEnvSchema, _clientEnv); } module.exports = { From f5af1c95767dab58d7084f1f3089be244ecb2b23 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:49 -0300 Subject: [PATCH 2/9] docs: explain how to declare env variables with envField --- .../environment-vars-config.mdx | 49 ++++++++++--------- .../src/content/docs/recipes/sentry-setup.mdx | 21 ++------ 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index 5903d8dd4..a9c07f0e6 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -10,7 +10,7 @@ import Code from '../../../components/code.astro'; Managing environment variables in your project is an essential task, but it can also be challenging. That's why we have included a complete setup for environment variables in this project. This setup comes with validation and type-checking using the `zod` library. -All the code related to environment variables is located in the `env.js` and `src/lib/env.js` files. The `env.js` read the `APP_ENV` variable and loads the correct `.env` file, then defines the `zod` schema for the environment variables for client and build-time, parses the `_env` object, and returns the parsed object, or throws errors in case of invalid or missing variables. +All the code related to environment variables is located in the `env.js`, `env-fields.js` and `src/lib/env.js` files. The `env.js` read the `APP_ENV` variable and loads the correct `.env` file, then defines the `zod` schema for the environment variables for client and build-time using the `envField` helpers from `env-fields.js`, reads the declared variables from `process.env`, and returns the parsed object, or throws errors in case of invalid or missing variables. To increase security, we are splitting environment variables into two parts: @@ -28,38 +28,39 @@ To add a new environment variable to the project, follow these steps: 1. Add the new environment variable to the correct `zod` schema inside the `env.js` file based on this simple rule : If the variable is used in the `src` folder, add it to the `client` schema, otherwise add it to the `buildTime` schema. +Declare it with one of the `envField` builders (`envField.string()`, `envField.number()` or `envField.boolean()`). The value is read from `process.env` automatically and converted to the declared type, so this is the only place where you need to declare the variable. + This will ensure that the new variable is validated correctly. and make sure we are only sending the correct vars to the client side Here's an example: ```js title="env.js" -const client = z.object({ +const clientEnvSchema = z.object({ // ... // add the new environment variable here/ accessible on the client side and build time(app.config.ts) - NEW_ENV_VAR: z.string(), + NEW_ENV_VAR: envField.string(), + NEW_NUMBER_VAR: envField.number({ default: 4321 }), + NEW_FLAG_VAR: envField.boolean({ optional: true }), }); -const buildTime = z.object({ +const buildTimeEnvSchema = z.object({ // ... // add the new environment variable here / accessible only on build time(app.config.ts) - NEW_SECRET_ENV: z.string(), + NEW_SECRET_ENV: envField.string(), }); ``` -2. Add the new environment variable to the correct env object inside the `env.js`, `_clientEnv` for client variables and `_buildTimeEnv` for build time variables. Here's an example: +2. Pick the right options for the variable. Every `envField` builder accepts the following options: -```js title="env.js" -const _clientEnv = { - // ... - // add the new environment variable here - NEW_ENV_VAR: process.env.NEW_ENV_VAR, -}; +| Option | Description | +| ---------- | ------------------------------------------------------------------------------------ | +| `optional` | When `true`, the variable can be missing or empty and its value will be `undefined`. | +| `default` | Value used when the variable is missing or empty. | -const _buildTimeEnv = { - // ... - // add the new environment variable here - NEW_SECRET_ENV: process.env.NEW_SECRET_ENV, -}; -``` +Without options, the variable is required. Empty values (e.g. `API_URL=`) are treated as missing. Values are converted as follows: + +- `envField.string()`: the value is used as is. +- `envField.number()`: the value is converted with `Number`; values that are not numbers (e.g. `abc`) are rejected. +- `envField.boolean()`: only `true` and `false` are accepted; any other value is rejected. 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: @@ -108,7 +109,7 @@ As mentioned earlier, `zod` is used to validate environment variables at runtime ``` :::note[Important] -As `dotenv` handles variables as strings, you need to convert them to the correct type while reading them from the `process.env` object. check the example below for more details on how to use numbers and boolean values. +As `dotenv` handles variables as strings, the `envField` builders convert them to the declared type (`string`, `number` or `boolean`) before validating them, so you don't need to parse them yourself. ::: ## How it works @@ -123,7 +124,7 @@ If you take a look at the `env.js` file, you will notice that the file is split we define some static variables for the app such as the app name, bundle Id and package. While these variables can be added to the `.env` files, we recommend keeping them in the `env.js` file as they are not meant to change. To handle different app variants, you can add suffixes to these variables using the `withEnvSuffix` function. -**In the second part**, we define the `zod` schema for the environment variables. +**In the second part**, we define the `zod` schema for the environment variables. Variables loaded from the `.env` file are declared with the `envField` builders, while static values defined in `env.js` (such as `NAME` or `BUNDLE_ID`) are declared with plain `zod` schemas and their values are set in the `clientStaticValues` and `buildTimeStaticValues` objects. We split the environment variables into two parts: @@ -133,9 +134,13 @@ We split the environment variables into two parts: These schemas are used to validate the environment variables. All the environment variables should be added to the correct schema. -We use the `z.infer` utility to infer the environment variables' types from the schema and use it to define the `_clientEnv` and `_buildTimeEnv` objects' type. This means that if you add a new environment variable to the schema, you will get a type error if you don't add it to the correct `_clientEnv` and `_buildTimeEnv` object as well, and vice versa. +The values of the variables declared in each schema are read from `process.env` automatically, so each variable is declared only once. We use the `z.infer` utility to infer the environment variables' types from the schema, so `Env` and `ClientEnv` are typed with the converted values (for example, `number` for `envField.number()`). + +The `envField` builders live in the `env-fields.js` file: + + -**Finally**, in the third part, we merge variables to `_env`, pare it using the zod schema, and return the parsed object as well as the client environment variable, or throw errors in case of invalid or missing variables. +**Finally**, in the third part, we merge variables to `_wholeEnv`, parse it using the zod schema, and return the parsed object as well as the client environment variable, or throw errors in case of invalid or missing variables. #### ✅ Use and send environment variables to the client diff --git a/docs/src/content/docs/recipes/sentry-setup.mdx b/docs/src/content/docs/recipes/sentry-setup.mdx index 884b2f217..6621377c2 100644 --- a/docs/src/content/docs/recipes/sentry-setup.mdx +++ b/docs/src/content/docs/recipes/sentry-setup.mdx @@ -49,28 +49,17 @@ The starter kit did not come with Sentry pre-configured, but it's very easy to s ```js title='env.js' // ... existing imports and configurations - const client = z.object({ + const clientEnvSchema = z.object({ // ... other client env vars - SENTRY_DSN: z.string().min(1, 'SENTRY_DSN is required'), + SENTRY_DSN: envField.string(), }); - const buildTime = z.object({ + const buildTimeEnvSchema = z.object({ // ... other build-time env vars - SENTRY_ORG: z.string().min(1, 'SENTRY_ORG is required'), - SENTRY_PROJECT: z.string().min(1, 'SENTRY_PROJECT is required'), + SENTRY_ORG: envField.string(), + SENTRY_PROJECT: envField.string(), }); - const _clientEnv = { - // ... other client env vars - SENTRY_DSN: process.env.SENTRY_DSN, - }; - - const _buildTimeEnv = { - // ... other build-time env vars - SENTRY_ORG: process.env.SENTRY_ORG, - SENTRY_PROJECT: process.env.SENTRY_PROJECT, - }; - // ... rest of the file ``` From 33373de134bb5396bd44ba96f7c3505beb4c0a06 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:05:30 -0300 Subject: [PATCH 3/9] fix: reject non-finite env numbers and test env helpers --- __tests__/env-fields.test.ts | 75 +++++++++++++++++++++++++++++++++++- env-fields.js | 42 ++++++++++++++++++-- env.js | 35 +++-------------- 3 files changed, 118 insertions(+), 34 deletions(-) diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts index 7b7b16238..d2db6c9b1 100644 --- a/__tests__/env-fields.test.ts +++ b/__tests__/env-fields.test.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { envField } from '../env-fields'; +import { envField, parseWithoutValidation, readEnv } from '../env-fields'; describe('envField', () => { describe('string', () => { @@ -42,6 +42,8 @@ describe('envField', () => { it('rejects values that are not numbers', () => { expect(envField.number().safeParse('abc').success).toBe(false); expect(envField.number().safeParse(' ').success).toBe(false); + expect(envField.number().safeParse('Infinity').success).toBe(false); + expect(envField.number().safeParse('-Infinity').success).toBe(false); }); it('rejects missing and empty values by default', () => { @@ -111,3 +113,74 @@ describe('envField', () => { }); }); }); + +describe('readEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('reads the declared variables from process.env', () => { + process.env.API_URL = 'https://api.example.com'; + process.env.UNDECLARED_VAR = 'ignored'; + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + }); + + expect(readEnv(schema, {})).toEqual({ + API_URL: 'https://api.example.com', + VAR_NUMBER: undefined, + }); + }); + + it('overrides process.env with the static values', () => { + process.env.NAME = 'FromEnvFile'; + const schema = z.object({ NAME: z.string() }); + + expect(readEnv(schema, { NAME: 'StaticName' })).toEqual({ + NAME: 'StaticName', + }); + }); +}); + +describe('parseWithoutValidation', () => { + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: envField.number(), + VAR_BOOL: envField.boolean({ default: false }), + }); + + it('parses the valid values', () => { + expect( + parseWithoutValidation(schema, { + API_URL: 'https://api.example.com', + VAR_NUMBER: '42', + VAR_BOOL: 'true', + }), + ).toEqual({ + API_URL: 'https://api.example.com', + VAR_NUMBER: 42, + VAR_BOOL: true, + }); + }); + + it('returns undefined for invalid or missing values without throwing', () => { + expect( + parseWithoutValidation(schema, { VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }), + ).toEqual({ + API_URL: undefined, + VAR_NUMBER: undefined, + VAR_BOOL: undefined, + }); + }); + + it('applies default values for missing values', () => { + expect(parseWithoutValidation(schema, {}).VAR_BOOL).toBe(false); + }); +}); diff --git a/env-fields.js b/env-fields.js index 8cfb2e456..c826ba73b 100644 --- a/env-fields.js +++ b/env-fields.js @@ -60,7 +60,7 @@ function createEnvField(schema, parse, options) { } /** - * Numbers are parsed with `Number`; values that are not numbers (e.g. `abc`) are rejected. + * Numbers are parsed with `Number`; values that are not finite numbers (e.g. `abc` or `Infinity`) are rejected. * @param {string} value */ const parseNumber = value => (value.trim() === '' ? Number.NaN : Number(value)); @@ -95,7 +95,11 @@ const envField = { * @param {O} [options] */ number: options => - createEnvField(z.number(), parseNumber, /** @type {O} */ (options ?? {})), + createEnvField( + z.number().finite(), + parseNumber, + /** @type {O} */ (options ?? {} + )), /** * @template {EnvFieldOptions} [O={}] * @param {O} [options] @@ -108,4 +112,36 @@ const envField = { ), }; -module.exports = { envField }; +/** + * Reads the raw value of every variable declared in the schema from `process.env`, + * then overrides them with the static values defined in `env.js`. + * @param {z.AnyZodObject} schema + * @param {Record} staticValues + * @returns {Record} the raw values, ready to be parsed with the schema + */ +function readEnv(schema, staticValues) { + return { + ...Object.fromEntries( + Object.keys(schema.shape).map(key => [key, process.env[key]]), + ), + ...staticValues, + }; +} + +/** + * Parses every variable on its own, keeping `undefined` for the invalid ones instead of failing. + * Used when the env variables don't need to be validated. + * @param {z.AnyZodObject} schema + * @param {Record} values + * @returns {Record} the parsed values + */ +function parseWithoutValidation(schema, values) { + return Object.fromEntries( + Object.entries(schema.shape).map(([key, fieldSchema]) => { + const parsed = fieldSchema.safeParse(values[key]); + return [key, parsed.success ? parsed.data : undefined]; + }), + ); +} + +module.exports = { envField, parseWithoutValidation, readEnv }; diff --git a/env.js b/env.js index 1dcf571ff..359f14b67 100644 --- a/env.js +++ b/env.js @@ -15,7 +15,11 @@ */ const z = require('zod'); -const { envField } = require('./env-fields'); +const { + envField, + parseWithoutValidation, + readEnv, +} = require('./env-fields'); const packageJSON = require('./package.json'); const path = require('node:path'); @@ -163,35 +167,6 @@ const buildTimeStaticValues = { EAS_PROJECT_ID, }; -/** - * Reads the raw value of every variable declared in the schema from `process.env`, - * then overrides them with the static values defined in this file. - * @param {z.AnyZodObject} schema - * @param {Record} staticValues - * @returns {Record} - */ -const readEnv = (schema, staticValues) => ({ - ...Object.fromEntries( - Object.keys(schema.shape).map((key) => [key, process.env[key]]), - ), - ...staticValues, -}); - -/** - * Parses every variable on its own, keeping `undefined` for the invalid ones instead of failing. - * Used when the env variables don't need to be validated. - * @param {z.AnyZodObject} schema - * @param {Record} values - * @returns {Record} - */ -const parseWithoutValidation = (schema, values) => - Object.fromEntries( - Object.entries(schema.shape).map(([key, fieldSchema]) => { - const parsed = fieldSchema.safeParse(values[key]); - return [key, parsed.success ? parsed.data : undefined]; - }), - ); - const _clientEnv = readEnv(clientEnvSchema, clientStaticValues); const _buildTimeEnv = readEnv(buildTimeEnvSchema, buildTimeStaticValues); From 6b07fb45b212d5cc02c940f854b536518845c94c Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:05:36 -0300 Subject: [PATCH 4/9] docs: keep env steps list intact and show envField import --- .../content/docs/getting-started/environment-vars-config.mdx | 4 ++-- docs/src/content/docs/recipes/sentry-setup.mdx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index a9c07f0e6..bd19e4e1f 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -28,7 +28,7 @@ To add a new environment variable to the project, follow these steps: 1. Add the new environment variable to the correct `zod` schema inside the `env.js` file based on this simple rule : If the variable is used in the `src` folder, add it to the `client` schema, otherwise add it to the `buildTime` schema. -Declare it with one of the `envField` builders (`envField.string()`, `envField.number()` or `envField.boolean()`). The value is read from `process.env` automatically and converted to the declared type, so this is the only place where you need to declare the variable. + Declare it with one of the `envField` builders (`envField.string()`, `envField.number()` or `envField.boolean()`). The value is read from `process.env` automatically and converted to the declared type, so this is the only place where you need to declare the variable. This will ensure that the new variable is validated correctly. and make sure we are only sending the correct vars to the client side Here's an example: @@ -59,7 +59,7 @@ const buildTimeEnvSchema = z.object({ Without options, the variable is required. Empty values (e.g. `API_URL=`) are treated as missing. Values are converted as follows: - `envField.string()`: the value is used as is. -- `envField.number()`: the value is converted with `Number`; values that are not numbers (e.g. `abc`) are rejected. +- `envField.number()`: the value is converted with `Number`; values that are not finite numbers (e.g. `abc` or `Infinity`) are rejected. - `envField.boolean()`: only `true` and `false` are accepted; any other value is rejected. 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: diff --git a/docs/src/content/docs/recipes/sentry-setup.mdx b/docs/src/content/docs/recipes/sentry-setup.mdx index 6621377c2..be3f7c74a 100644 --- a/docs/src/content/docs/recipes/sentry-setup.mdx +++ b/docs/src/content/docs/recipes/sentry-setup.mdx @@ -48,6 +48,7 @@ The starter kit did not come with Sentry pre-configured, but it's very easy to s ```js title='env.js' // ... existing imports and configurations + const { envField } = require('./env-fields'); const clientEnvSchema = z.object({ // ... other client env vars From 86bab76389e3bf227a5c22edeb5dae4cab8b4503 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:08:07 -0300 Subject: [PATCH 5/9] fix: accept only decimal env numbers and cover env.js loading --- __tests__/env-fields.test.ts | 10 +++ __tests__/env.test.ts | 83 +++++++++++++++++++ .../environment-vars-config.mdx | 2 +- env-fields.js | 13 ++- 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 __tests__/env.test.ts diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts index d2db6c9b1..9ca2403c5 100644 --- a/__tests__/env-fields.test.ts +++ b/__tests__/env-fields.test.ts @@ -44,6 +44,9 @@ describe('envField', () => { expect(envField.number().safeParse(' ').success).toBe(false); expect(envField.number().safeParse('Infinity').success).toBe(false); expect(envField.number().safeParse('-Infinity').success).toBe(false); + expect(envField.number().safeParse('0x10').success).toBe(false); + expect(envField.number().safeParse('1e3').success).toBe(false); + expect(envField.number().safeParse(' 42 ').success).toBe(false); }); it('rejects missing and empty values by default', () => { @@ -101,6 +104,13 @@ describe('envField', () => { }); }); + it('uses the default value when both optional and default are set', () => { + const schema = envField.string({ optional: true, default: 'fallback' }); + + expect(schema.parse(undefined)).toBe('fallback'); + expect(schema.parse('value')).toBe('value'); + }); + it('reports missing variables as required when used in an object schema', () => { const result = z .object({ API_URL: envField.string(), VAR_NUMBER: envField.number() }) diff --git a/__tests__/env.test.ts b/__tests__/env.test.ts new file mode 100644 index 000000000..f4a614a96 --- /dev/null +++ b/__tests__/env.test.ts @@ -0,0 +1,83 @@ +const VALID_ENV = { + API_URL: 'https://api.example.com', + VAR_NUMBER: '10', + VAR_BOOL: 'true', + TERMS_OF_SERVICE_URL: 'https://example.com/terms', + WEBSITE_URL: 'https://example.com', + SECRET_KEY: 'fake-secret-key', +}; + +const RUN_SCRIPT = 'expo start'; +const OTHER_SCRIPT = 'jest'; + +type EnvModule = { + Env: Record; + ClientEnv: Record; +}; + +const loadEnv = (values: Record, script: string) => { + process.env = { + ...process.env, + ...values, + APP_ENV: 'development', + EAS_BUILD: 'false', + npm_lifecycle_script: script, + }; + let envModule: EnvModule | undefined; + jest.isolateModules(() => { + envModule = require('../env'); + }); + return envModule as EnvModule; +}; + +describe('env', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('reads and converts the declared variables when validating', () => { + const { Env, ClientEnv } = loadEnv(VALID_ENV, RUN_SCRIPT); + + expect(Env).toMatchObject({ + API_URL: 'https://api.example.com', + VAR_NUMBER: 10, + VAR_BOOL: true, + SECRET_KEY: 'fake-secret-key', + NAME: 'RootstrapApp', + }); + expect(ClientEnv.VAR_NUMBER).toBe(10); + expect(ClientEnv).not.toHaveProperty('SECRET_KEY'); + }); + + it('keeps the static values over the ones in process.env', () => { + const { Env } = loadEnv({ ...VALID_ENV, NAME: 'OtherName' }, RUN_SCRIPT); + + expect(Env.NAME).toBe('RootstrapApp'); + }); + + it('throws when a variable is invalid and the env has to be validated', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + loadEnv({ ...VALID_ENV, VAR_NUMBER: 'abc' }, RUN_SCRIPT), + ).toThrow('Invalid environment variables'); + expect(consoleError).toHaveBeenCalled(); + }); + + it('returns undefined for invalid variables when the env is not validated', () => { + const { Env, ClientEnv } = loadEnv( + { ...VALID_ENV, VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }, + OTHER_SCRIPT, + ); + + expect(Env.VAR_NUMBER).toBeUndefined(); + expect(Env.VAR_BOOL).toBeUndefined(); + expect(Env.API_URL).toBe('https://api.example.com'); + expect(ClientEnv.VAR_NUMBER).toBeUndefined(); + }); +}); diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index bd19e4e1f..dea24216c 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -59,7 +59,7 @@ const buildTimeEnvSchema = z.object({ Without options, the variable is required. Empty values (e.g. `API_URL=`) are treated as missing. Values are converted as follows: - `envField.string()`: the value is used as is. -- `envField.number()`: the value is converted with `Number`; values that are not finite numbers (e.g. `abc` or `Infinity`) are rejected. +- `envField.number()`: only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value (e.g. `abc`, `Infinity`, `0x10` or `1e3`) is rejected. - `envField.boolean()`: only `true` and `false` are accepted; any other value is rejected. 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: diff --git a/env-fields.js b/env-fields.js index c826ba73b..a98281ef0 100644 --- a/env-fields.js +++ b/env-fields.js @@ -59,11 +59,16 @@ function createEnvField(schema, parse, options) { ); } +const DECIMAL_NUMBER_REGEX = /^-?\d+(?:\.\d+)?$/; + /** - * Numbers are parsed with `Number`; values that are not finite numbers (e.g. `abc` or `Infinity`) are rejected. + * Only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value + * (e.g. `abc`, `Infinity`, `0x10`, `1e3` or values with spaces) is rejected. * @param {string} value */ -const parseNumber = value => (value.trim() === '' ? Number.NaN : Number(value)); +function parseNumber(value) { + return DECIMAL_NUMBER_REGEX.test(value) ? Number(value) : Number.NaN; +} /** * Only `true` and `false` are accepted; any other value is rejected. @@ -98,8 +103,8 @@ const envField = { createEnvField( z.number().finite(), parseNumber, - /** @type {O} */ (options ?? {} - )), + /** @type {O} */ (options ?? {}), + ), /** * @template {EnvFieldOptions} [O={}] * @param {O} [options] From f8efca3f11ebfcd212c961781a81335fd111f612 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:28:12 -0300 Subject: [PATCH 6/9] fix: add actionable messages to env field errors --- __tests__/env-fields.test.ts | 21 +++++++++++++++++++++ __tests__/env.test.ts | 14 +++++++++++--- env-fields.js | 28 ++++++++++++++++++++++++---- env.js | 2 +- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts index 9ca2403c5..4306823dc 100644 --- a/__tests__/env-fields.test.ts +++ b/__tests__/env-fields.test.ts @@ -2,6 +2,9 @@ import { z } from 'zod'; import { envField, parseWithoutValidation, readEnv } from '../env-fields'; +const getErrorMessages = (schema: z.ZodTypeAny, value: unknown) => + schema.safeParse(value).error?.issues.map(issue => issue.message); + describe('envField', () => { describe('string', () => { it('returns the value as is', () => { @@ -49,6 +52,15 @@ describe('envField', () => { expect(envField.number().safeParse(' 42 ').success).toBe(false); }); + it('describes the accepted format and the received value when invalid', () => { + expect(getErrorMessages(envField.number(), 'abc')).toEqual([ + 'must be a plain decimal number (e.g. 42 or -3.5), received "abc"', + ]); + expect(getErrorMessages(envField.number({ optional: true }), '1e3')).toEqual([ + 'must be a plain decimal number (e.g. 42 or -3.5), received "1e3"', + ]); + }); + it('rejects missing and empty values by default', () => { expect(envField.number().safeParse(undefined).success).toBe(false); expect(envField.number().safeParse('').success).toBe(false); @@ -83,6 +95,15 @@ describe('envField', () => { expect(envField.boolean().safeParse('TRUE').success).toBe(false); }); + it('describes the accepted format and the received value when invalid', () => { + expect(getErrorMessages(envField.boolean(), 'yes')).toEqual([ + 'must be \'true\' or \'false\', received "yes"', + ]); + expect(getErrorMessages(envField.boolean({ default: false }), '1')).toEqual([ + 'must be \'true\' or \'false\', received "1"', + ]); + }); + it('rejects missing and empty values by default', () => { expect(envField.boolean().safeParse(undefined).success).toBe(false); expect(envField.boolean().safeParse('').success).toBe(false); diff --git a/__tests__/env.test.ts b/__tests__/env.test.ts index f4a614a96..936be0bfc 100644 --- a/__tests__/env.test.ts +++ b/__tests__/env.test.ts @@ -58,15 +58,23 @@ describe('env', () => { expect(Env.NAME).toBe('RootstrapApp'); }); - it('throws when a variable is invalid and the env has to be validated', () => { + it('throws and logs how to fix the invalid variables when the env has to be validated', () => { const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => {}); expect(() => - loadEnv({ ...VALID_ENV, VAR_NUMBER: 'abc' }, RUN_SCRIPT), + loadEnv( + { ...VALID_ENV, VAR_NUMBER: 'abc', VAR_BOOL: 'yes' }, + RUN_SCRIPT, + ), ).toThrow('Invalid environment variables'); - expect(consoleError).toHaveBeenCalled(); + expect(consoleError.mock.calls[0][1]).toMatchObject({ + VAR_NUMBER: [ + 'must be a plain decimal number (e.g. 42 or -3.5), received "abc"', + ], + VAR_BOOL: ['must be \'true\' or \'false\', received "yes"'], + }); }); it('returns undefined for invalid variables when the env is not validated', () => { diff --git a/env-fields.js b/env-fields.js index a98281ef0..99d445b4c 100644 --- a/env-fields.js +++ b/env-fields.js @@ -59,15 +59,31 @@ function createEnvField(schema, parse, options) { ); } +/** + * Builds a zod error map that replaces the type errors of invalid values with a message + * describing the accepted format and the received value, so it's clear how to fix it. + * Missing values keep the default `Required` message. + * @param {string} expected description of the accepted format + * @returns {z.ZodErrorMap} the error map + */ +function invalidValueErrorMap(expected) { + return (issue, ctx) => { + if (issue.code === 'invalid_type' && ctx.data !== undefined) { + return { message: `must be ${expected}, received ${JSON.stringify(ctx.data)}` }; + } + return { message: ctx.defaultError }; + }; +} + const DECIMAL_NUMBER_REGEX = /^-?\d+(?:\.\d+)?$/; /** * Only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value - * (e.g. `abc`, `Infinity`, `0x10`, `1e3` or values with spaces) is rejected. + * (e.g. `abc`, `Infinity`, `0x10`, `1e3` or values with spaces) is returned as is so it's rejected. * @param {string} value */ function parseNumber(value) { - return DECIMAL_NUMBER_REGEX.test(value) ? Number(value) : Number.NaN; + return DECIMAL_NUMBER_REGEX.test(value) ? Number(value) : value; } /** @@ -101,7 +117,11 @@ const envField = { */ number: options => createEnvField( - z.number().finite(), + z + .number({ + errorMap: invalidValueErrorMap('a plain decimal number (e.g. 42 or -3.5)'), + }) + .finite(), parseNumber, /** @type {O} */ (options ?? {}), ), @@ -111,7 +131,7 @@ const envField = { */ boolean: options => createEnvField( - z.boolean(), + z.boolean({ errorMap: invalidValueErrorMap('\'true\' or \'false\'') }), parseBoolean, /** @type {O} */ (options ?? {}), ), diff --git a/env.js b/env.js index 359f14b67..618b75af1 100644 --- a/env.js +++ b/env.js @@ -203,7 +203,7 @@ if (shouldValidateEnv) { '❌ Invalid environment variables:', parsedWholeEnv.error.flatten().fieldErrors, - `\n❌ Missing variables in \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file. Make sure all required variables are defined in the \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file.`, + `\n❌ Missing or invalid variables in \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file. Make sure all required variables are defined with valid values in the \x1b[1m\x1b[4m\x1b[31m${envFile}\x1b[0m file.`, ]; if (isLocalBuild) { From 2d6ce5d6d70e05c991c833edd9cfde443575439d Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:28:34 -0300 Subject: [PATCH 7/9] fix: accept case-insensitive booleans in env fields --- __tests__/env-fields.test.ts | 10 +++++++++- .../docs/getting-started/environment-vars-config.mdx | 2 +- env-fields.js | 8 +++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts index 4306823dc..bc1ab1d25 100644 --- a/__tests__/env-fields.test.ts +++ b/__tests__/env-fields.test.ts @@ -89,10 +89,18 @@ describe('envField', () => { expect(envField.boolean().parse('false')).toBe(false); }); + it('parses "true" and "false" case-insensitively', () => { + expect(envField.boolean().parse('TRUE')).toBe(true); + expect(envField.boolean().parse('True')).toBe(true); + expect(envField.boolean().parse('FALSE')).toBe(false); + expect(envField.boolean().parse('False')).toBe(false); + }); + it('rejects any other value', () => { expect(envField.boolean().safeParse('yes').success).toBe(false); expect(envField.boolean().safeParse('1').success).toBe(false); - expect(envField.boolean().safeParse('TRUE').success).toBe(false); + expect(envField.boolean().safeParse('0').success).toBe(false); + expect(envField.boolean().safeParse(' true ').success).toBe(false); }); it('describes the accepted format and the received value when invalid', () => { diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index dea24216c..5c8921bb8 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -60,7 +60,7 @@ Without options, the variable is required. Empty values (e.g. `API_URL=`) are tr - `envField.string()`: the value is used as is. - `envField.number()`: only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value (e.g. `abc`, `Infinity`, `0x10` or `1e3`) is rejected. -- `envField.boolean()`: only `true` and `false` are accepted; any other value is rejected. +- `envField.boolean()`: only `true` and `false` are accepted, case-insensitively (e.g. `TRUE` or `False`); any other value (e.g. `1` or `yes`) is rejected. 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: diff --git a/env-fields.js b/env-fields.js index 99d445b4c..f51c357f4 100644 --- a/env-fields.js +++ b/env-fields.js @@ -87,14 +87,16 @@ function parseNumber(value) { } /** - * Only `true` and `false` are accepted; any other value is rejected. + * Only `true` and `false` are accepted, case-insensitively (e.g. `TRUE` or `False`); + * any other value (e.g. `1`, `yes` or values with spaces) is returned as is so it's rejected. * @param {string} value */ function parseBoolean(value) { - if (value === 'true') { + const normalizedValue = value.toLowerCase(); + if (normalizedValue === 'true') { return true; } - if (value === 'false') { + if (normalizedValue === 'false') { return false; } return value; From cc573831168502c873ca65b3a28a98beb2d44a6b Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:05 -0300 Subject: [PATCH 8/9] fix: guard against env vars declared without envField --- __tests__/env-fields.test.ts | 29 ++++++++++++++++++- env-fields.js | 55 ++++++++++++++++++++++++++++-------- env.js | 1 + 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/__tests__/env-fields.test.ts b/__tests__/env-fields.test.ts index bc1ab1d25..262d88e6b 100644 --- a/__tests__/env-fields.test.ts +++ b/__tests__/env-fields.test.ts @@ -1,6 +1,11 @@ import { z } from 'zod'; -import { envField, parseWithoutValidation, readEnv } from '../env-fields'; +import { + envField, + isEnvField, + parseWithoutValidation, + readEnv, +} from '../env-fields'; const getErrorMessages = (schema: z.ZodTypeAny, value: unknown) => schema.safeParse(value).error?.issues.map(issue => issue.message); @@ -153,6 +158,16 @@ describe('envField', () => { }); }); +describe('isEnvField', () => { + it('detects the schemas created with the envField builders', () => { + expect(isEnvField(envField.string())).toBe(true); + expect(isEnvField(envField.number({ optional: true }))).toBe(true); + expect(isEnvField(envField.boolean({ default: false }))).toBe(true); + expect(isEnvField(z.string())).toBe(false); + expect(isEnvField(z.preprocess(value => value, z.number()))).toBe(false); + }); +}); + describe('readEnv', () => { const originalEnv = process.env; @@ -186,6 +201,18 @@ describe('readEnv', () => { NAME: 'StaticName', }); }); + + it('throws when a variable without a static value is not declared with envField', () => { + process.env.VAR_NUMBER = '42'; + const schema = z.object({ + API_URL: envField.string(), + VAR_NUMBER: z.number(), + }); + + expect(() => readEnv(schema, {})).toThrow( + 'The env variable "VAR_NUMBER" is read from process.env but it isn\'t declared with an envField builder', + ); + }); }); describe('parseWithoutValidation', () => { diff --git a/env-fields.js b/env-fields.js index f51c357f4..ce0255f62 100644 --- a/env-fields.js +++ b/env-fields.js @@ -29,6 +29,21 @@ const z = require('zod'); * @typedef {z.ZodEffects, EnvFieldInner['_output'], unknown>} EnvField */ +/** + * Schemas created with the `envField` builders, used to make sure that every variable + * read from `process.env` is converted from a string to its declared type. + * @type {WeakSet} + */ +const envFieldSchemas = new WeakSet(); + +/** + * @param {z.ZodTypeAny} schema + * @returns {boolean} whether the schema was created with one of the `envField` builders + */ +function isEnvField(schema) { + return envFieldSchemas.has(schema); +} + /** * Builds an env field schema: raw values are converted with `parse` (empty strings become `undefined`) * and then validated with `schema`, applying the `optional` and `default` options. @@ -49,14 +64,15 @@ function createEnvField(schema, parse, options) { inner = schema.optional(); } - return /** @type {EnvField} */ ( - z.preprocess((value) => { - if (value === undefined || value === '') { - return undefined; - } - return typeof value === 'string' ? parse(value) : value; - }, inner) - ); + const envFieldSchema = z.preprocess((value) => { + if (value === undefined || value === '') { + return undefined; + } + return typeof value === 'string' ? parse(value) : value; + }, inner); + envFieldSchemas.add(envFieldSchema); + + return /** @type {EnvField} */ (envFieldSchema); } /** @@ -142,15 +158,30 @@ const envField = { /** * Reads the raw value of every variable declared in the schema from `process.env`, * then overrides them with the static values defined in `env.js`. + * Variables without a static value must be declared with an `envField` builder, as their raw + * value is always a string; otherwise an error is thrown. * @param {z.AnyZodObject} schema * @param {Record} staticValues * @returns {Record} the raw values, ready to be parsed with the schema */ function readEnv(schema, staticValues) { + /** @type {[string, z.ZodTypeAny][]} */ + const fields = Object.entries(schema.shape); + const envValues = fields + .filter(([key]) => !Object.hasOwn(staticValues, key)) + .map(([key, fieldSchema]) => { + if (!isEnvField(fieldSchema)) { + throw new Error( + `The env variable "${key}" is read from process.env but it isn't declared with an envField builder. ` + + 'Declare it with envField.string(), envField.number() or envField.boolean() in env.js, ' + + 'or set its value in the static values.', + ); + } + return [key, process.env[key]]; + }); + return { - ...Object.fromEntries( - Object.keys(schema.shape).map(key => [key, process.env[key]]), - ), + ...Object.fromEntries(envValues), ...staticValues, }; } @@ -171,4 +202,4 @@ function parseWithoutValidation(schema, values) { ); } -module.exports = { envField, parseWithoutValidation, readEnv }; +module.exports = { envField, isEnvField, parseWithoutValidation, readEnv }; diff --git a/env.js b/env.js index 618b75af1..3223b5ec9 100644 --- a/env.js +++ b/env.js @@ -116,6 +116,7 @@ const withEnvSuffix = (name) => * 1. If you need your variable on the client-side, you should add it to the client schema; otherwise, you should add it to the buildTime schema. * 2. Declare variables read from the `.env` file with `envField.string()`, `envField.number()` or `envField.boolean()`. * Their values are read from `process.env` and converted to the declared type automatically, so you only declare them once. + * Every variable without a static value is read from `process.env`, so it must use an `envField` builder: declaring it with a plain zod schema (e.g. `z.number()`) throws an error. * 3. Static values defined in this file (such as NAME or BUNDLE_ID) are declared with plain zod schemas and their values are set in `clientStaticValues` or `buildTimeStaticValues`. * * Note: env fields are required by default; an empty value (e.g. `API_URL=`) is treated as missing. From 73aaa19eb61435a49098cb054a8f55262139fc57 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:51 -0300 Subject: [PATCH 9/9] docs: document env validation scope and stricter env parsing --- .../getting-started/environment-vars-config.mdx | 14 +++++++++++++- env.js | 4 ++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/getting-started/environment-vars-config.mdx b/docs/src/content/docs/getting-started/environment-vars-config.mdx index 5c8921bb8..84cfe0159 100644 --- a/docs/src/content/docs/getting-started/environment-vars-config.mdx +++ b/docs/src/content/docs/getting-started/environment-vars-config.mdx @@ -30,6 +30,8 @@ To add a new environment variable to the project, follow these steps: Declare it with one of the `envField` builders (`envField.string()`, `envField.number()` or `envField.boolean()`). The value is read from `process.env` automatically and converted to the declared type, so this is the only place where you need to declare the variable. + Every variable without a static value (see `clientStaticValues` and `buildTimeStaticValues`) is read from `process.env`, so it must be declared with an `envField` builder. Declaring it with a plain `zod` schema (e.g. `z.number()`) throws an error naming the variable, because its raw value would always be a string. + This will ensure that the new variable is validated correctly. and make sure we are only sending the correct vars to the client side Here's an example: @@ -62,6 +64,12 @@ Without options, the variable is required. Empty values (e.g. `API_URL=`) are tr - `envField.number()`: only plain decimal numbers (e.g. `42` or `-3.5`) are accepted; any other value (e.g. `abc`, `Infinity`, `0x10` or `1e3`) is rejected. - `envField.boolean()`: only `true` and `false` are accepted, case-insensitively (e.g. `TRUE` or `False`); any other value (e.g. `1` or `yes`) is rejected. +Invalid values are reported with the accepted format and the received value, for example `VAR_BOOL=yes` is reported as `must be 'true' or 'false', received "yes"`. + +:::caution[Upgrading from a previous version of the template] +Number and boolean variables used to be converted manually in `env.js` with a looser conversion: any boolean value other than `true` (such as `1` or `yes`) silently became `false`, and numbers such as `1e3`, `0x10` or values with surrounding spaces were accepted. The `envField` builders are stricter, so those values now fail validation instead. If you pull template updates (for example, through the `sync-with-upstream.yml` workflow), review your `.env` files and the env files used by your CI and EAS builds, and make sure number variables are plain decimal numbers and boolean variables are `true` or `false`. +::: + 3. Add the new environment variable to your `.env` files. Make sure to include it in all relevant files (`development`, `staging`, and `production`). Here's an example: ```bash title=".env.{APP_ENV}" @@ -104,10 +112,14 @@ As mentioned earlier, `zod` is used to validate environment variables at runtime ```bash ❌ Invalid environment variables: { TEST: [ 'Required' ] } -❌ Missing variables in .env.development file, Make sure all required variables are defined in the .env.development file. +❌ Missing or invalid variables in .env.development file. Make sure all required variables are defined with valid values in the .env.development file. 💡 Tip: If you recently updated the .env.development file and the error still persists, try restarting the server with the -c flag to clear the cache. ``` +:::note[When validation runs] +The variables are only validated when running the app (`expo start` or `expo run`) or when building it, on EAS or locally, with one of the commands listed in `ENVIRONMENT_DEPENDANT_SCRIPTS` in `env.js` (such as `eas build`, `expo prebuild` or `expo export`). Only these flows fail fast on missing or invalid variables. For any other command (for example, `eas update`), validation is skipped: missing or invalid values end up as `undefined` in `Env` and `ClientEnv`, and therefore in the `extra` field of the app config, without any error. +::: + :::note[Important] As `dotenv` handles variables as strings, the `envField` builders convert them to the declared type (`string`, `number` or `boolean`) before validating them, so you don't need to parse them yourself. ::: diff --git a/env.js b/env.js index 3223b5ec9..fa3c90f2a 100644 --- a/env.js +++ b/env.js @@ -228,6 +228,10 @@ if (shouldValidateEnv) { Env = parsedWholeEnv.data; ClientEnv = clientEnvSchema.parse(_clientEnv); } else { + // The env variables are only validated when running the app or when building it with one of the ENVIRONMENT_DEPENDANT_SCRIPTS + // (for example, `eas update` isn't one of them). + // For any other script, missing or invalid values don't fail: they end up as `undefined` in `Env` and `ClientEnv`, + // and therefore in the `extra` field of the app config. // Don't worry about TypeScript here; if we don't need to validate the env variables is because we aren't using them //@ts-ignore Env = parseWithoutValidation(wholeEnvSchema, _wholeEnv);