Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .changeset/cyan-loops-hear.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
"arkenv": minor
---

### Schema-Directed Coercion
### Coercion

Introduced **Schema-Directed Coercion**, a robust new system for handling environment variable transformations.
Introduced **Schema-Directed Coercion**: now, environment variables defined as `number` or `boolean` in your schema are automatically parsed to their correct types.

* **Automatic Coercion**: Environment variables defined as `number` or `boolean` in your schema are now automatically coerced from strings.
* **Standard-Based**: Uses standard JSON Schema introspection (`.toJsonSchema()`) to identify coercion targets safely and reliably.
* **Performance**: Optimized traversal ensures high-performance processing without internal mutations.
To learn more about the new coercion system, read [the docs](https://arkenv.js.org/docs/arkenv/coercion).

### ⚠️ Breaking Changes

* **`boolean` keyword**: The custom `boolean` morph has been removed. Use `arktype`'s standard `boolean` instead, which will be coerced when used within `createEnv` / `arkenv`.
* **`port` keyword**: Now a strict numeric refinement (0-65535). It no longer parses strings automatically outside of `createEnv` / `arkenv`.

If your only usage of these types was inside `createEnv` / `arkenv`, or following the official Bun / Vite plugin examples, you should not be affected by these changes.
4 changes: 2 additions & 2 deletions apps/playgrounds/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const env = arkenv({
HOST: "string.ip | 'localhost'",
PORT: "0 <= number.integer <= 65535",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
DEBUG: "boolean = true",
DEBUGGING: "boolean = false",
});

// Automatically validate and parse process.env
Expand All @@ -13,7 +13,7 @@ console.log({
host: env.HOST,
port: env.PORT,
nodeEnv: env.NODE_ENV,
debug: env.DEBUG,
debugging: env.DEBUGGING,
});

export default env;
7 changes: 3 additions & 4 deletions apps/www/components/banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ export function Banner() {
return (
<FumadocsBanner
variant="rainbow"
id="standard-schema-support-banner"
onClick={() => router.push("/docs/integrations/standard-schema")}
id="coercion-banner"
onClick={() => router.push("/docs/arkenv/coercion")}
className="cursor-pointer"
>
🎉 Standard Schema support is here: Use ArkEnv with Zod, Valibot, and
more!
🎉 Coercion is here: Auto convert strings to numbers, booleans, etc.
</FumadocsBanner>
);
}
74 changes: 74 additions & 0 deletions apps/www/content/docs/arkenv/coercion.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Coercion
description: Automatically transform environment variable strings into typesafe numbers, booleans, and more.
icon: New
---

Environment variables are always strings by default. ArkEnv automatically **coerces** these strings into their target types based on your schema, so you only have to think about the types you want.

## Numbers

Any field defined as a `number` or a numeric subtype (like `number.integer` or `number.port`) will be automatically parsed.

```ts title="index.ts"
import arkenv from "arkenv";

const env = arkenv({
PORT: "number.port",
RETRY_COUNT: "number.integer = 3",
AGE: "0 <= number.integer <= 120"
});

// All are numbers!
console.log({
port: env.PORT,
retryCount: env.RETRY_COUNT,
age: env.AGE
});
```

## Booleans

The `boolean` type handles strings like `"true"` and `"false"`.

```ts title="index.ts"
import arkenv from "arkenv";

const env = arkenv({
DEBUG: "boolean = false"
});

// DEBUG is true if process.env.DEBUG is "true"
console.log({
debug: env.DEBUG
});
```

## Advanced Coercion

### Submodules and Refinements

Coercion is smart enough to look through ArkType refinements and submodules. Even if you use complex types like `string.ip | 'localhost'`, ArkEnv ensures the base types are handled correctly.

```ts title="index.ts"
import arkenv from "arkenv";

const env = arkenv({
HOST: "string.ip | 'localhost'",
MAX_CONNECTIONS: "number.integer > 0"
});
```

## How it works

ArkEnv introspects your schema to identify fields that should be numbers, booleans, and other non-string types you define. When you call `createEnv()` (or `arkenv()`), it pre-processes your environment variables to perform these conversions *before* validation.

## Performance

Coercion is highly optimized: ArkEnv performs a one-time introspection of your schema to map out exactly which paths need conversion. At runtime, it only touches the variables that actually need coercion, keeping your startup time virtually identical to manually parsing `process.env`.

## Disabling coercion

At the moment, there is no way to disable coercion or get around it with `arkenv`.

If this is something you're interested in, please open an issue on [GitHub](https://github.com/arkenv/arkenv/issues/new/choose) or send a [Discord message](https://discord.gg/zAmUyuxXH9).
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
title: Standard Schema validators
description: You can use any Standard Schema validator with ArkEnv.
icon: New
---

Since [arktype@2.1.28](https://github.com/arktypeio/arktype/releases/tag/arktype%402.1.28), ArkType supports **any validator that implements the [Standard Schema](https://standardschema.dev/) specification**.
Expand Down
4 changes: 2 additions & 2 deletions apps/www/content/docs/arkenv/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
"quickstart",
"examples",
"---API---",
"morphs",
"coercion",
"---Integrations---",
"[VS Code](/docs/arkenv/integrations/vscode)",
"[Cursor, Antigravity, VSCodium](/docs/arkenv/integrations/open-vsx)",
"[JetBrains](/docs/arkenv/integrations/jetbrains)",
"[New][Standard Schema](/docs/arkenv/integrations/standard-schema)",
"[Standard Schema](/docs/arkenv/integrations/standard-schema)",
"---How-to---",
"[Load environment variables](/docs/arkenv/how-to/load-environment-variables)",
"[Reuse your schema](/docs/arkenv/how-to/reuse-schemas)"
Expand Down
62 changes: 0 additions & 62 deletions apps/www/content/docs/arkenv/morphs.mdx

This file was deleted.

5 changes: 5 additions & 0 deletions apps/www/source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ const arktypeTwoslashOptions = {
compilerOptions: {
// avoid ... in certain longer types on hover
noErrorTruncation: true,
baseUrl: "../../",
paths: {
arkenv: ["packages/arkenv/src"],
"@repo/*": ["packages/internal/*/src"],
},
},
extraFiles: {
"global.d.ts": `import type * as a from "arktype"
Expand Down
4 changes: 2 additions & 2 deletions examples/basic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const env = arkenv({
HOST: "string.ip | 'localhost'",
PORT: "0 <= number.integer <= 65535",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
DEBUG: "boolean = true",
DEBUGGING: "boolean = false",
});

// Automatically validate and parse process.env
Expand All @@ -13,7 +13,7 @@ console.log({
host: env.HOST,
port: env.PORT,
nodeEnv: env.NODE_ENV,
debug: env.DEBUG,
debugging: env.DEBUGGING,
});

export default env;
29 changes: 9 additions & 20 deletions packages/arkenv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,38 +41,27 @@ ArkEnv is an environment variable parser powered by [ArkType](https://arktype.io
import arkenv from "arkenv";

const env = arkenv({
HOST: "string.host", // valid IP address or localhost
PORT: "number.port", // valid port number (0-65535)
NODE_ENV: "'development' | 'production' | 'test'",
HOST: "string.ip | 'localhost'",
PORT: "0 <= number.integer <= 65535",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
DEBUGGING: "boolean = false",
});

// Hover to see ✨exact✨ types
const host = env.HOST;
const port = env.PORT;
const nodeEnv = env.NODE_ENV;
const debugging = env.DEBUGGING;
```

With ArkEnv, your environment variables are **guaranteed to match your schema**. If any variable is incorrect or missing, the app won't start and a clear error will be thrown:

```bash title="Terminal"
❯ PORT=hello npm start

ArkEnvError: Errors found while validating environment variables
HOST must be a string or "localhost" (was missing)
PORT must be an integer between 0 and 65535 (was "hello")
```

## Coercion

Environment variables are always strings, but ArkEnv automatically coerces them to their target types when possible:

- `number` and subtypes (`number.port`, `number.epoch`) are parsed as numbers.
- `boolean` strings ("true", "false") are parsed as booleans.

```ts
const env = arkenv({
PORT: "number", // "3000" → 3000
DEBUG: "boolean", // "true" → true
TIMESTAMP: "number.epoch" // "1640995200000" → 1640995200000
});
PORT must be a number (was a string)
```

## Features
Expand All @@ -82,7 +71,7 @@ const env = arkenv({
- Tiny: <1kB gzipped
- Build-time and runtime validation
- Single import, zero config for most projects
- Validated, defaultable, typesafe environment variables
- Validated, defaultable, coerced, typesafe environment variables
- Powered by ArkType, TypeScript's 1:1 validator
- Compatible with any Standard Schema validator (Zod, Valibot, etc.)
- Optimized from editor to runtime
Expand Down
44 changes: 44 additions & 0 deletions packages/arkenv/src/create-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,50 @@ describe("createEnv", () => {
});
});

describe("numeric keywords", () => {
it("should coerce number", () => {
const env = createEnv({ VAL: "number" }, { VAL: "123.456" });
expect(env.VAL).toBe(123.456);
});

it("should coerce number.Infinity", () => {
const env = createEnv({ VAL: "number.Infinity" }, { VAL: "Infinity" });
expect(env.VAL).toBe(Number.POSITIVE_INFINITY);
});

// TODO: Support NaN coercion
// it("should coerce number.NaN", () => {
// const env = createEnv({ VAL: "number.NaN" }, { VAL: "NaN" });
// expect(env.VAL).toBeNaN();
// });

it("should coerce number.NegativeInfinity", () => {
const env = createEnv(
{ VAL: "number.NegativeInfinity" },
{ VAL: "-Infinity" },
);
expect(env.VAL).toBe(Number.NEGATIVE_INFINITY);
});

it("should coerce number.epoch", () => {
const env = createEnv({ VAL: "number.epoch" }, { VAL: "1640995200000" });
expect(env.VAL).toBe(1640995200000);
});

it("should coerce number.integer", () => {
const env = createEnv({ VAL: "number.integer" }, { VAL: "42" });
expect(env.VAL).toBe(42);
});

it("should coerce number.safe", () => {
const env = createEnv(
{ VAL: "number.safe" },
{ VAL: "9007199254740991" },
);
expect(env.VAL).toBe(Number.MAX_SAFE_INTEGER);
});
});

it("should validate string env variables", () => {
process.env.TEST_STRING = "hello";

Expand Down
3 changes: 2 additions & 1 deletion packages/internal/keywords/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const maybeParsedNumber = type("unknown").pipe((s) => {
if (typeof s === "number") return s;
if (typeof s !== "string" || s.trim() === "") return s;
const n = Number(s);
return Number.isNaN(n) ? s : n;
if (Number.isNaN(n) && s !== "NaN") return s;
return n;
});

/**
Expand Down