Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
da4be5e
`type` Coercion Implementation (#565)
yamcodes Dec 20, 2025
c0e0a14
Global schema transformer (#570)
yamcodes Dec 21, 2025
1728e55
Improve coercion types (#572)
yamcodes Dec 22, 2025
07fd3dd
Schema-Directed Coercion JSON Schema (100% Public API Coercion implem…
yamcodes Dec 22, 2025
ed36e78
Merge branch 'main' into coercion
yamcodes Dec 22, 2025
513f9f0
Merge branch 'main' into coercion
yamcodes Dec 22, 2025
a03eee6
Merge branch 'main' into coercion
yamcodes Dec 22, 2025
32522e3
fix(coerce): support schemas with morphs (#593)
yamcodes Dec 22, 2025
78936c3
feat(arkenv): Fix coercion crashes for predicates (#595)
yamcodes Dec 22, 2025
167ba08
Coercion playgrounds/examples (#596)
yamcodes Dec 22, 2025
84c1145
Coercion Documentation (#597)
yamcodes Dec 22, 2025
1483d57
NaN Coercion (#603)
yamcodes Dec 22, 2025
68956d5
Option to disable coercion (#602)
yamcodes Dec 22, 2025
2f85b3f
docs: Archive coercion public API design documents and move final spe…
yamcodes Dec 22, 2025
20f31d4
docs: Archive `update-hero-videos` change documents and promote the h…
yamcodes Dec 22, 2025
59e162c
chore: Update `VITE_MY_VAR` type from `string` to `unknown`.
yamcodes Dec 22, 2025
f1dd521
feat: update literal coercion spec to expect successful string-to-num…
yamcodes Dec 22, 2025
bb12b6f
docs: Detail path mapping rules and execution flow for the coercion p…
yamcodes Dec 22, 2025
8d8bb6c
feat: Update spec to require coercion of strings to numbers for numer…
yamcodes Dec 22, 2025
5924c6f
fix: correct typo in coercion spec
yamcodes Dec 22, 2025
1ecde2e
fix: Reference `toJsonSchema` instead of `json` for ArkType API in co…
yamcodes Dec 22, 2025
788fc01
test: Add cases for array element coercion and validation failures
yamcodes Dec 22, 2025
dd2d649
chore: Adjust bundle size goal to 2KB across documentation and update…
yamcodes Dec 22, 2025
6e39da2
chore: Increase bun-plugin bundle size limit to 2.2 kB.
yamcodes Dec 22, 2025
864e285
refactor: consolidate stable introspection and pipeline-based coercio…
yamcodes Dec 22, 2025
e14bc5f
refactor: Update `VITE_MY_VAR` type from `string` to `unknown` in Vit…
yamcodes Dec 22, 2025
e656a71
docs: Define stable coercion mechanism using public ArkType APIs, doc…
yamcodes Dec 22, 2025
9631c67
test: add test for case-sensitive boolean coercion expecting an error…
yamcodes Dec 22, 2025
ea59bc0
docs: add user-facing behavior documentation and elaborate on coercio…
yamcodes Dec 22, 2025
973473a
docs: Refine coercion public API design by detailing null/undefined k…
yamcodes Dec 22, 2025
55e27bb
docs: Add details on performance, mutation, limitations, and alternat…
yamcodes Dec 22, 2025
a9a7e72
Merge branch 'main' into coercion
yamcodes Dec 22, 2025
3a7623f
Update openspec/specs/coercion/spec.md
yamcodes Dec 23, 2025
d7a7d1d
docs: Improve clarity of coercion specification and simplify test com…
yamcodes Dec 23, 2025
934493c
style: Standardize scenario formatting in the coercion specification.
yamcodes Dec 23, 2025
fec2e63
docs: Fix punctuation in coercion spec.
yamcodes Dec 23, 2025
0680c1a
docs: fix punctuation in coercion spec
yamcodes Dec 23, 2025
78a1cd0
feat: add 404 Not Found page and update pnpm lockfile
yamcodes Dec 23, 2025
9cef3a9
test: adjust `arkenv` `loadEnv` assertion to use object property syntax
yamcodes Dec 23, 2025
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
21 changes: 21 additions & 0 deletions .changeset/cuddly-ears-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@arkenv/vite-plugin": minor
---

### Breaking Change: `createEnv` API Update

The underlying `arkenv` package has updated its `createEnv` API. This affects users who manually call `arkenv` (or `createEnv`) within their `vite.config.ts` to Type-Safe environment variables during config loading.

If you are using this pattern:

```ts
const env = arkenv(Env, loadEnv(mode, process.cwd(), ""));
```

You must update it to:

```ts
const env = arkenv(Env, { env: loadEnv(mode, process.cwd(), "") });
```

The plugin usage itself (`plugins: [arkenv(Env)]`) remains unchanged.
22 changes: 22 additions & 0 deletions .changeset/cyan-loops-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"arkenv": minor
---

### Coercion

Introduced **Schema-Directed Coercion**: now, environment variables defined as `number` or `boolean` in your schema are automatically parsed to their correct types.

If you want to opt-out of this feature, pass `config.coerce: false` to `createEnv()` (`arkenv()`). Example:

```ts
arkenv(schema, { coerce: false });
```

To learn more about the new coercion system, read [the docs](https://arkenv.js.org/docs/arkenv/coercion).

### ⚠️ Breaking Changes

* **`createEnv` API**: The `createEnv` function signature has changed to support a configuration object.
Instead of `createEnv(schema, env)`, use `createEnv(schema, config)` where `config` includes `env` and the newly added `coerce` option (`true` by default).
* **`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`.
9 changes: 9 additions & 0 deletions .changeset/humble-lizards-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@repo/keywords": minor
---

#### Simplify keywords for central coercion

* **BREAKING**: The `boolean` keyword has been removed. Universal boolean coercion is now handled by the `arkenv` package.
* **BREAKING**: The `port` keyword has been changed from a `string -> number` morph to a pure `number` refinement. Numeric coercion is now handled centrally.
* Added `maybeParsedNumber` and `maybeParsedBoolean` internal morphs to support central coercion (including specific "NaN" support).
8 changes: 8 additions & 0 deletions .changeset/open-spiders-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@repo/scope": minor
---

#### Align scope with central coercion

* **BREAKING**: Removed the custom `boolean` keyword from the root scope. ArkEnv now uses the standard ArkType `boolean` primitive combined with global coercion.
* Updated `number.port` to use the new strict numeric refinement, as string parsing is now handled by global coercion.
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ These examples demonstrate real-world usage and can serve as templates for new i
- **Avoid breaking changes** to the public API without explicit approval and a major version changeset
- **Don't modify generated files** like `pnpm-lock.yaml` directly - use `pnpm install` instead
- **Don't skip changesets** for published packages - always run `pnpm changeset` for version bumps
- **Avoid adding new dependencies** without considering bundle size impact (aspirational goal: <1kB gzipped, enforced limit: 2kB gzipped)
- **Avoid adding new dependencies** without considering bundle size impact (aspirational goal: <2kB gzipped, enforced limit: 2kB gzipped)

### Security Considerations
- Always validate user input in examples and documentation
Expand Down
2 changes: 1 addition & 1 deletion apps/playgrounds/bun-react/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type } from "arkenv";

export default type({
BUN_PUBLIC_API_URL: "string",
BUN_PUBLIC_API_URL: "string.url",
BUN_PUBLIC_DEBUG: "boolean",
});
6 changes: 1 addition & 5 deletions apps/playgrounds/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ const env = arkenv({

// Automatically validate and parse process.env
// TypeScript knows the ✨exact✨ types!
const host = env.HOST;
const port = env.PORT;
const nodeEnv = env.NODE_ENV;

console.log({ host, port, nodeEnv });
console.log({ host: env.HOST, port: env.PORT, nodeEnv: env.NODE_ENV });

export default env;
3 changes: 0 additions & 3 deletions apps/playgrounds/node/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
HOST=localhost
PORT=3000
NODE_ENV=development
DEBUG=true
ZED_ENV=development
27 changes: 8 additions & 19 deletions apps/playgrounds/node/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import arkenv, { type } from "arkenv";
import * as z from "zod";
import arkenv from "arkenv";

const env = arkenv({
HOST: "string.host",
PORT: "number.port",
HOST: "string.ip | 'localhost'",
PORT: "0 <= number.integer <= 65535",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
ALLOWED_ORIGINS: type("string[]").default(() => []),
DEBUG: "boolean = true",
ZED_ENV: z.string(),
DEBUGGING: "boolean = false",
});

// Automatically validate and parse process.env
// TypeScript knows the ✨exact✨ types!
const host = env.HOST;
const port = env.PORT;
const nodeEnv = env.NODE_ENV;
const allowedOrigins = env.ALLOWED_ORIGINS;
const debug = env.DEBUG;
const zedEnv = env.ZED_ENV;
console.log({
host,
port,
nodeEnv,
allowedOrigins,
debug,
zedEnv,
host: env.HOST,
port: env.PORT,
nodeEnv: env.NODE_ENV,
debugging: env.DEBUGGING,
});

export default env;
35 changes: 12 additions & 23 deletions apps/playgrounds/standard-schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const env = arkenv({
DEBUG: "boolean = false",

// Zod validators (great for complex validation and transformations)
DATABASE_URL: z.string().url(),
DATABASE_URL: z.url(),
API_KEY: z
.string()
.min(32)
Expand All @@ -23,35 +23,24 @@ const env = arkenv({
ALLOWED_ORIGINS: z
.string()
.transform((str: string) => str.split(","))
.pipe(z.array(z.string().url())),
.pipe(z.array(z.url())),

// Array defaults with ArkType
FEATURE_FLAGS: type("string[]").default(() => []),
});

// All validators work together seamlessly with full type inference
const host: string = env.HOST;
const port: number = env.PORT;
const nodeEnv: "development" | "production" | "test" = env.NODE_ENV;
const debug: boolean = env.DEBUG;
const databaseUrl: string = env.DATABASE_URL;
const apiKey: string = env.API_KEY;
const maxRetries: number = env.MAX_RETRIES;
const timeoutMs: number = env.TIMEOUT_MS;
const allowedOrigins: string[] = env.ALLOWED_ORIGINS;
const featureFlags: string[] = env.FEATURE_FLAGS;

console.log({
host,
port,
nodeEnv,
debug,
databaseUrl,
apiKey: `${apiKey.substring(0, 8)}...`, // Don't log full API key
maxRetries,
timeoutMs,
allowedOrigins,
featureFlags,
host: env.HOST,
port: env.PORT,
nodeEnv: env.NODE_ENV,
debug: env.DEBUG,
databaseUrl: env.DATABASE_URL,
apiKey: `${env.API_KEY.substring(0, 8)}...`, // Don't log full API key
maxRetries: env.MAX_RETRIES,
timeoutMs: env.TIMEOUT_MS,
allowedOrigins: env.ALLOWED_ORIGINS,
featureFlags: env.FEATURE_FLAGS,
});

export default env;
8 changes: 4 additions & 4 deletions apps/playgrounds/vite-legacy/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import tsconfigPaths from "vite-tsconfig-paths";
// 2. Validating VITE_* variables via the plugin
export const Env = type({
PORT: "number.port",
VITE_MY_VAR: "string",
VITE_MY_NUMBER: type("string").pipe((str) => Number.parseInt(str, 10)),
VITE_MY_BOOLEAN: type("string").pipe((str) => str === "true"),
VITE_MY_VAR: "unknown",
VITE_MY_NUMBER: "number",
VITE_MY_BOOLEAN: "boolean",
});

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = arkenv(Env, loadEnv(mode, process.cwd(), ""));
const env = arkenv(Env, { env: loadEnv(mode, process.cwd(), "") });

console.log(`${env.VITE_MY_NUMBER} ${typeof env.VITE_MY_NUMBER}`);
return {
Expand Down
45 changes: 23 additions & 22 deletions apps/playgrounds/vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,24 @@ import { defineConfig, loadEnv } from "vite";

// Define the schema once
export const Env = type({
PORT: "number.port", // Server-only (used in vite.config)
VITE_MY_VAR: "string", // Client-exposed
VITE_MY_NUMBER: type("string").pipe((str) => Number.parseInt(str, 10)),
VITE_MY_BOOLEAN: type("string").pipe((str) => str === "true"),
PORT: "number.port", // Server-only (used in vite.config)
VITE_MY_VAR: "string", // Client-exposed
VITE_MY_NUMBER: "number",
VITE_MY_BOOLEAN: "boolean",
});

export default defineConfig(({ mode }) => {
// Validate server-side variables (PORT) using loadEnv
const env = arkenv(Env, loadEnv(mode, process.cwd(), ""));

return {
plugins: [
arkenvVitePlugin(Env), // Validates VITE_* variables
],
server: {
port: env.PORT, // Use validated PORT
},
};
// Validate server-side variables (PORT) using loadEnv
const env = arkenv(Env, { env: loadEnv(mode, process.cwd(), "") });

return {
plugins: [
arkenvVitePlugin(Env), // Validates VITE_* variables
],
server: {
port: env.PORT, // Use validated PORT
},
};
});
```

Expand All @@ -47,12 +47,12 @@ The playground includes type augmentation for `import.meta.env`:
/// <reference types="vite/client" />

type ImportMetaEnvAugmented =
import("@arkenv/vite-plugin").ImportMetaEnvAugmented<
typeof import("../vite.config").Env
>;
import("@arkenv/vite-plugin").ImportMetaEnvAugmented<
typeof import("../vite.config").Env
>;

interface ViteTypeOptions {
strictImportMetaEnv: unknown;
strictImportMetaEnv: unknown;
}

interface ImportMetaEnv extends ImportMetaEnvAugmented {}
Expand All @@ -62,10 +62,10 @@ This makes `import.meta.env` fully typesafe in your React components:

```tsx title="src/app.tsx"
// All of these are typesafe!
const myVar = import.meta.env.VITE_MY_VAR; // ✅ string
const myNumber = import.meta.env.VITE_MY_NUMBER; // ✅ number
const myVar = import.meta.env.VITE_MY_VAR; // ✅ string
const myNumber = import.meta.env.VITE_MY_NUMBER; // ✅ number
const myBoolean = import.meta.env.VITE_MY_BOOLEAN; // ✅ boolean
const port = import.meta.env.PORT; // ❌ Error: PORT is server-only
const port = import.meta.env.PORT; // ❌ Error: PORT is server-only
```

## Environment Variables
Expand All @@ -80,6 +80,7 @@ VITE_MY_BOOLEAN=true
```

The plugin automatically:

- Validates all variables at build-time
- Filters to only expose `VITE_*` variables to the client
- Excludes server-only variables (like `PORT`) from the client bundle
Expand Down
8 changes: 4 additions & 4 deletions apps/playgrounds/vite/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import tsconfigPaths from "vite-tsconfig-paths";
// 2. Validating VITE_* variables via the plugin
export const Env = type({
PORT: "number.port",
VITE_MY_VAR: "string",
VITE_MY_NUMBER: type("string").pipe((str) => Number.parseInt(str, 10)),
VITE_MY_BOOLEAN: type("string").pipe((str) => str === "true"),
VITE_MY_VAR: "unknown",
VITE_MY_NUMBER: "number",
VITE_MY_BOOLEAN: "boolean",
});

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = arkenv(Env, loadEnv(mode, process.cwd(), ""));
const env = arkenv(Env, { env: loadEnv(mode, process.cwd(), "") });

console.log(`${env.VITE_MY_NUMBER} ${typeof env.VITE_MY_NUMBER}`);
return {
Expand Down
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>
);
}
Loading
Loading