Coercion and parsing

Turn string environment variables into numbers, booleans, arrays, and objects.

Edit on GitHub

Environment variables arrive as strings. ArkEnv coerces those strings toward the types your schema declares so application code can treat PORT as a number and DEBUG as a boolean.

The same coerce option and pre-coercion pipeline apply to @arkenv/core and @arkenv/standard, including Zod and Valibot.

Defaults

Coercion is on by default (coerce: true). Zod 4.2+ uses z.number() and z.boolean(), and Valibot uses v.number() and v.boolean(); ArkEnv converts "8080" and "true" before the schema runs.

./env.ts
import  from "@arkenv/core";

export const  = (
  {
    PORT: "number.port",
    DEBUG: "boolean = false",
    TAGS: "string[]",
  },
  {
    : {
      PORT: "8080",
      DEBUG: "true",
      TAGS: "web, api",
    },
  },
);

.PORT; // number
.DEBUG; // boolean
.TAGS; // string[]

Numbers

PORT=3000 arrives as the string "3000". Parsing it here is what makes port + 1 equal 3001.

Any field typed as number or a numeric subtype (including ArkType number.port, Zod z.int(), or Valibot v.number()) is parsed from the string form:

./env.ts
import  from "@arkenv/core";

export const  = (
  {
    PORT: "number.port",
    RETRY_COUNT: "number.integer = 3",
    AGE: "0 <= number.integer <= 120",
  },
  {
    : {
      PORT: "8080",
      RETRY_COUNT: "5",
      AGE: "30",
    },
  },
);

env.PORT;
const env: {
    PORT: number;
    RETRY_COUNT: number;
    AGE: number;
}

Booleans

boolean accepts the lowercase strings "true" and "false" only. Values like "0", "1", "True", "YES", or "on" are not coerced and fail validation. The same rule applies to ArkType boolean, Zod z.boolean(), and Valibot v.boolean().

./env.ts
import  from "@arkenv/core";

export const  = (
  { DEBUG: "boolean = false" },
  { : { DEBUG: "true" } },
);

env.DEBUG;
const env: {
    DEBUG: boolean;
}

Need a wider truthy set? Normalize with a transform (an ArkType morph, Zod .transform, or Valibot v.transform), or keep the field as string and map it in application code.

Arrays

A list in an env file is still one string. Splitting on commas is how TAGS=web, api becomes ["web", "api"].

By default ArkEnv splits on commas and trims each entry:

./env.ts
import  from "@arkenv/core";

export const  = (
  {
    TAGS: "string[]",
    PORTS: "number[]",
  },
  {
    : {
      TAGS: "web, app, api",
      PORTS: "3000, 8080",
    },
  },
);

env.TAGS;
const env: {
    TAGS: string[];
    PORTS: number[];
}
env.PORTS;
const env: {
    TAGS: string[];
    PORTS: number[];
}

Switch to JSON arrays with arrayFormat: "json":

./env.ts
import  from "@arkenv/core";

export const  = (
  { TAGS: "string[]" },
  {
    : "json",
    : { TAGS: '["web", "app"]' },
  },
);

env.TAGS;
const env: {
    TAGS: string[];
}

Objects

A nested config has to fit in one environment variable, so the usual shape is a JSON string. ArkEnv parses that string, then coerces nested fields the same way it coerces top-level ones.

./env.ts
import  from "@arkenv/core";

export const  = (
  {
    : {
      HOST: "string",
      PORT: "number",
    },
  },
  {
    : {
      DATABASE: '{"HOST": "localhost", "PORT": "5432"}',
    },
  },
);

env.DATABASE;
const env: {
    DATABASE: {
        HOST: string;
        PORT: number;
    };
}

Pre-coercion model

ArkEnv does not wrap your schema in a coercion pipe. It uses a shared pre-coercion pipeline for both engines:

  1. Introspect the schema's JSON Schema (ArkType .in.toJsonSchema, or Standard Schema metadata / optional toJsonSchema fallback)
  2. Find paths that need conversion (numbers, booleans, arrays, objects)
  3. Copy the input record and coerce only those paths on the copy
  4. Validate the copy with the original, unmodified schema

process.env is never mutated. Schema transforms and refinements still see the coerced values.

Standard Schema ≠ Standard JSON Schema

Automatic coercion with @arkenv/standard needs Standard JSON Schema v1 on the value or a converter. Those specs are orthogonal to Standard Schema validation. Zod 4.2+ and VineJS 4.3+ put the converter on the schema. Valibot and Zod Mini use first-class subpaths (@arkenv/standard/valibot, @arkenv/standard/zod-mini). Zod v3 typically uses zod-to-json-schema through the toJsonSchema escape hatch. See Valibot and Zod.

Transforms

A transform reshapes a value after it is a valid input: trim a string, clamp a number, map an enum. Schema libraries call these transforms, morphs, or pipes. ArkEnv runs them as part of parsing.

PORT still uses built-in coercion, not a custom pipe:

./env.ts
import , {  } from "@arkenv/core";

export const  = (
  {
    : ("string").((value) => value.trim().toUpperCase()),
    PORT: "number.port = 3000",
  },
  { : { BUILD_ID: "  abc  " } },
);

.BUILD_ID; // "ABC"

Coercion can feed a transformation. The string "3" becomes a number before your pipe runs:

./env.ts
import , {  } from "@arkenv/core";

export const  = (
  {
    : ("number").((n) => Math.max(0, n)),
  },
  { : { RETRY_COUNT: "3" } },
);

.RETRY_COUNT; // 3
NeedPrefer
"3000" → number, "true" → booleanCoercion (on by default)
Trim, normalize, clamp, map enumsTransform on the field schema
Multi-step pipelinesArkType .pipe / Zod .pipe / Valibot pipe

Valibot (toJsonSchema)

Prefer @arkenv/standard/valibot so @valibot/to-json-schema is bound for you (typeMode: "input", target: "draft-07"):

./env.ts
import {  } from "@arkenv/standard/valibot";
import * as  from "valibot";

export const  = ({ PORT: .number(), DEBUG: .boolean() });

Install @valibot/to-json-schema yourself; it is an optional peer of @arkenv/standard. Recipe: Valibot.

The optional toJsonSchema callback on root @arkenv/standard remains the escape hatch for custom converters. ArkEnv calls it when it can't read JSON Schema from that key:

./env.ts
import  from "@arkenv/standard";
import * as  from "valibot";
import { toJsonSchema } from "@valibot/to-json-schema";

export const  = (
  { PORT: .number(), DEBUG: .boolean() },
  {
    : (schema) =>
      toJsonSchema(schema as ., {
        : "input",
        : "draft-07",
      }),
  },
);
  • target: "draft-07" matches the JSON Schema draft ArkEnv expects.
  • typeMode: "input" coerces env strings toward the schema's input type. A bare { toJsonSchema } function reference uses Valibot's default typeMode: "ignore".
  • Valibot's converter does not accept Standard Schema, so assert as v.GenericSchema at the call.

Zod v3

Same escape hatch: Zod v3 validates via Standard Schema but omits Standard JSON Schema on the value. Convert with zod-to-json-schema (works with zod@3 or Zod 4's zod/v3 export):

./env.ts
import  from "@arkenv/standard";
import {  } from "zod/v3";
import {  } from "zod-to-json-schema";

export const  = (
  { : .(), : .() },
  {
    : (schema) =>
      (schema as ., {
        : "none",
      }),
  },
);

Recipe: Zod.

Mixing with Zod Mini

Prefer @arkenv/standard/zod-mini for Mini-only maps. Mini has no ~standard.jsonSchema and no instance .toJSONSchema(). When you mix Valibot and Mini on root @arkenv/standard, switch on schema["~standard"].vendor and return undefined for anything else. Mini reports vendor "zod". Classic Zod keys never reach the callback at runtime.

./env.ts
import  from "@arkenv/standard";
import { toJsonSchema } from "@valibot/to-json-schema";
import * as  from "valibot";
import * as  from "zod/mini";

export const  = (
  {
    PORT: .number(),
    : .(),
  },
  {
    : (schema) => {
      switch (schema["~standard"].) {
        case "valibot":
          return toJsonSchema(schema as ., {
            : "input",
            : "draft-07",
          });
        case "zod":
          return .(schema as ., {
            : "input",
            : "draft-07",
          });
        default:
          return ;
      }
    },
  },
);

Disabling coercion

Turn the pipeline off when a field must stay a string, or when you want the schema library to parse it.

./env.ts
import  from "@arkenv/core";

export const  = (
  { PORT: "string" },
  { : false, : { PORT: "3000" } },
);

.PORT; // string

With coerce: false, a numeric schema rejects "3000" unless you transform the string yourself (or use a helper such as z.coerce.number() or Valibot v.pipe(v.string(), v.transform(Number))).

Next steps