Skip to content

Commit ea0dbd6

Browse files
authored
Merge pull request #1157 from yamcodes/709-unify-error-normalization-and-formatting
(v1) Unify error normalization and formatting
2 parents d42ded2 + df221c2 commit ea0dbd6

52 files changed

Lines changed: 1623 additions & 332 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"arkenv": major
3+
---
4+
5+
#### Refactor error system to use normalized `EnvIssue` and add `{ safe: true }` API
6+
7+
Introduce a unified `EnvIssue` type for programmatic access to validation issues via `ArkEnvError.issues`, and add the non-throwing `{ safe: true }` configuration option to `arkenv`.
8+
9+
Error messages now use ANSI colors instead of a bullet-point prefix:
10+
11+
```diff
12+
- - [PORT] must be a valid port number (was "invalid-port")
13+
+ PORT must be a valid port number (was "invalid-port")
14+
```
15+
16+
Note: Header (red), variable path (yellow), and received value (cyan) are now styled with ANSI escape codes. Update any test suites asserting on exact error text.
17+
18+
**BREAKING CHANGE**: `ValidationIssue` and `formatInternalErrors` removed. Use `EnvIssue` and `formatIssues` instead.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,4 @@ gha-creds-*.json
8989
skills/*-workspace/
9090
skills/create-skill/scripts/__pycache__/
9191
tooling/
92+
job_logs.txt

apps/www/bin/twoslash-mdx.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// biome-ignore-all lint/suspicious/noConsole: This is a CLI debugging script
22
import fs from "node:fs";
33
import { createTwoslasher } from "twoslash";
4-
import { arktypeTwoslashOptions } from "../lib/twoslash-options";
4+
import { arktypeTwoslashOptions } from "~/lib/twoslash-options";
55

66
const mdxPath = process.argv[2];
77
if (!mdxPath) {

apps/www/components/page/compatibility-rails.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import {
99
} from "@icons-pack/react-simple-icons";
1010
import { Square } from "lucide-react";
1111
import type { JSX } from "react";
12+
import { ArkTypeIcon } from "~/components/icons/arktype-icon";
13+
import { JoiIcon } from "~/components/icons/joi-icon";
14+
import { SolidStartIcon } from "~/components/icons/solid-start-icon";
15+
import { TypiaIcon } from "~/components/icons/typia-icon";
16+
import { ValibotIcon } from "~/components/icons/valibot-icon";
17+
import { VinxiIcon } from "~/components/icons/vinxi-icon";
1218
import { cn } from "~/lib/utils";
13-
import { ArkTypeIcon } from "../icons/arktype-icon";
14-
import { JoiIcon } from "../icons/joi-icon";
15-
import { SolidStartIcon } from "../icons/solid-start-icon";
16-
import { TypiaIcon } from "../icons/typia-icon";
17-
import { ValibotIcon } from "../icons/valibot-icon";
18-
import { VinxiIcon } from "../icons/vinxi-icon";
1919

2020
type RailItem = {
2121
name: string;

apps/www/components/ui/popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22
import * as PopoverPrimitive from "@radix-ui/react-popover";
33
import * as React from "react";
4-
import { cn } from "../../lib/cn";
4+
import { cn } from "~/lib/cn";
55

66
const Popover = PopoverPrimitive.Root;
77

apps/www/content/docs/arkenv/options.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,32 @@ const env = arkenv(
8787
// env.PORT → 3000 (default applied)
8888
// env.DEBUG → false (default applied)
8989
```
90+
91+
## `safe`
92+
93+
Whether to return a safe result object instead of throwing an error on validation failure. Defaults to `false`.
94+
95+
When enabled, the function returns an object with `{ success: true, data }` or `{ success: false, issues }`.
96+
97+
```ts twoslash
98+
import arkenv from "arkenv";
99+
// ---cut---
100+
const result = arkenv(
101+
{ PORT: "number" },
102+
{ safe: true, env: { PORT: "invalid" } }
103+
);
104+
105+
if (!result.success) {
106+
console.error("Validation failed:", result.issues);
107+
} else {
108+
console.log("Port is:", result.data.PORT);
109+
}
110+
```
111+
112+
:::warn Not supported in Integrations
113+
The `safe: true` option is purposefully omitted from the configuration options of framework integrations (like `@arkenv/vite-plugin`, `@arkenv/nextjs`, etc.).
114+
115+
Framework integrations rely on injecting the raw, validated environment variables into the build process. If `safe: true` were enabled, a validation failure would result in the plugin silently injecting the `{ success: false, issues }` wrapper object into your application bundle instead of crashing the build, leading to broken downstream behavior.
116+
117+
If you need to handle validation failures programmatically without failing the build, you should invoke `arkenv()` manually rather than using an automated bundler plugin.
118+
:::

docs/CONTEXT.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ pnpm run test:e2e # E2E tests
176176
- Run `pnpm release` after merging PRs to publish packages
177177
- Only packages in `packages/` are published to npm
178178

179+
## Design Decisions
180+
181+
**Split Parsing Engines (ArkType vs Standard Schema):**
182+
183+
- ArkEnv maintains two distinct parsing engines: `src/arktype/index.ts` and `src/parse-standard.ts`.
184+
- Despite visual similarities, they are strictly isolated to guarantee the `arkenv/standard` module boundary remains "ArkType-free".
185+
- Unifying them would force bundlers to trace static imports and drag ArkType into the dependency tree of Standard Schema users, violating the zero-dependency goal.
186+
- We prioritize optimal tree-shaking, bundle size isolation, and decoupling over dogmatic DRYness.
187+
179188
## Domain context
180189

181190
**Environment Variable Validation:**
@@ -225,8 +234,12 @@ pnpm run test:e2e # E2E tests
225234
- Leverages ArkType's `type.infer` and `type.validate` utilities
226235
- Typesafe environment object returned from `arkenv`
227236

228-
**Error Handling:**
237+
**Error Handling & Vocabulary:**
229238

239+
- **Issue vs. Error Distinction**: ArkEnv strictly differentiates between an "Issue" and an "Error".
240+
- **Issue (`EnvIssue`)**: A single, isolated validation failure on a specific environment variable.
241+
- **Error (`ArkEnvError`)**: The overarching runtime exception that is thrown when validation fails. It contains an array of `EnvIssue`s.
242+
- Functions dealing with individual failures should use "Issue" (e.g., `formatIssues`), while functions dealing with the final halting exception should use "Error" (e.g., `ArkEnvError`).
230243
- `ArkEnvError` extends `Error` and formats ArkType validation errors
231244
- Errors include variable names and expected types
232245
- Fail-fast approach: app won't start if validation fails
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# 10. Bundle Isolation trumps DRYness
2+
3+
Date: 2026-06-21
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
ArkEnv operates as a zero-dependency environment variable parser. It uses ArkType as its primary validation engine (`arkenv`), while also offering a completely separate entry point (`arkenv/standard`) that leverages Standard Schema 1.0 (for users preferring Zod, Valibot, etc.).
12+
13+
During the refactoring to a single core repository with multiple internal exports, a code review raised concerns about code duplication between `src/arktype/index.ts` and `src/parse-standard.ts`. Both files implement similar logic for parsing objects, extracting issue metadata, and formatting validation results. The suggestion was to "aggressively unify or codeshare" these implementations to abide by the DRY (Don't Repeat Yourself) principle.
14+
15+
However, sharing utilities across the core ArkType engine and the Standard Schema engine introduces hidden module graph entanglements. Bundlers like Webpack, Rollup, and esbuild often rely on static imports for tree-shaking. A single shared `utils.ts` file imported by both entry points can easily trick the bundler's heuristics into statically tracing the dependency tree back to `arktype`. This would drag the entire 50kb+ ArkType AST engine into the production bundle of users who only wanted to use `arkenv/standard` with Zod.
16+
17+
## Decision
18+
19+
We intentionally duplicate parsing, formatting, and issue-mapping logic across the `arktype` and `standard` engine implementations to maintain an airtight module boundary. **Bundle isolation strictly trumps DRYness across core/standard boundaries.**
20+
21+
We will not create shared abstractions or utility files that bridge these two domains. The small maintenance cost of duplicated internal logic is a worthwhile trade-off to guarantee that `arkenv/standard` users never incur a bundle size penalty from ArkType.
22+
23+
## Consequences
24+
25+
- The footprint of `@arkenv/standard` remains strictly minimal and fully decoupled from ArkType.
26+
- Contributors must be aware that fixing a bug in the error extraction logic for ArkType may require a mirrored fix in the Standard Schema logic.
27+
- Future code reviews raising concerns about DRYness between these files should be directed to this ADR.

packages/arkenv/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
{
9090
"name": "arkenv",
9191
"path": "dist/index.mjs",
92-
"limit": "2.1 kB",
92+
"limit": "2.7 kB",
9393
"import": "*",
9494
"ignore": [
9595
"arktype"
@@ -98,7 +98,7 @@
9898
{
9999
"name": "arkenv/standard",
100100
"path": "dist/standard.mjs",
101-
"limit": "2.3 kB",
101+
"limit": "3.5 kB",
102102
"import": "*"
103103
},
104104
{

packages/arkenv/src/arkenv.ts

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
} from "@repo/types";
88
import type { type as at, distill } from "arktype";
99
import { parse } from "./arktype";
10+
import { ArkEnvError, type SafeArkEnvResult } from "./core";
11+
import { safeExecute } from "./utils/errors";
1012

1113
/**
1214
* Declarative environment schema definition accepted by ArkEnv.
@@ -35,7 +37,7 @@ export type Infer<T> = T extends SchemaShape
3537
: InferType<T>;
3638

3739
/**
38-
* The environment variables passed to `createEnv`.
40+
* The environment variables passed to `arkenv`.
3941
* Uses `Dict<string>` to enforce
4042
* compile-time safety: all input environment variables must be strings
4143
* (or undefined), matching `process.env` semantics.
@@ -80,6 +82,12 @@ export type ArkEnvConfig = {
8082
*/
8183
arrayFormat?: "comma" | "json";
8284

85+
/**
86+
* Whether to bypass secret redaction and print raw sensitive values during debugging.
87+
* Defaults to checking `process.env.ARKENV_DEBUG_SECRETS === "true"` or `"1"`.
88+
*/
89+
debugSecrets?: boolean;
90+
8391
/**
8492
* Whether to treat empty strings (`""`) as `undefined` before validation.
8593
*
@@ -90,37 +98,75 @@ export type ArkEnvConfig = {
9098
* @default false
9199
*/
92100
emptyAsUndefined?: boolean;
101+
102+
/**
103+
* Whether to return a safe result object instead of throwing an error on validation failure.
104+
*
105+
* When enabled, the function returns an object with `{ success: true, data }` or `{ success: false, issues }`.
106+
*
107+
* @default false
108+
*/
109+
safe?: boolean;
93110
};
94111

112+
export type { SafeArkEnvResult };
113+
95114
/**
96-
* TODO: `SchemaShape` is basically `Record<string, unknown>`.
97-
* If possible, find a better type than "const T extends Record<string, unknown>",
98-
* and be as close as possible to the type accepted by ArkType's `type`.
115+
* Helper type to represent the output of parsing either an EnvSchema or CompiledEnvSchema.
99116
*/
117+
export type ArkenvOutput<T extends SchemaShape, D> =
118+
| distill.Out<at.infer<T, $>>
119+
| InferType<D>;
100120

101121
/**
102122
* Utility to parse environment variables using ArkType or Standard Schema
103-
* @param def - The schema definition
104-
* @param config - The evaluation configuration
105-
* @returns The parsed environment variables
106-
* @throws An {@link ArkEnvError | error} if the environment variables are invalid.
123+
*
124+
* Naming convention: the main function is lowercase (`arkenv`) following the
125+
* JavaScript convention for functions (e.g. `zod`, `joi`). Classes and types
126+
* use PascalCase with the full product name (`ArkEnvError`, `SafeArkEnvResult`).
127+
*
128+
* @param def The schema definition
129+
* @param config The evaluation configuration
130+
* @returns The parsed environment variables, or a SafeArkEnvResult if `{ safe: true }` is configured
131+
* @throws An {@link ArkEnvError | error} if the environment variables are invalid and `safe` is not enabled
107132
*/
108133
export function arkenv<const T extends SchemaShape>(
109134
def: EnvSchema<T>,
110-
config?: ArkEnvConfig,
135+
config?: ArkEnvConfig & { safe?: false },
111136
): distill.Out<at.infer<T, $>>;
112137
export function arkenv<T extends CompiledEnvSchema>(
113138
def: T,
114-
config?: ArkEnvConfig,
139+
config?: ArkEnvConfig & { safe?: false },
115140
): InferType<T>;
141+
export function arkenv<
142+
const T extends SchemaShape,
143+
const D extends EnvSchema<T> | CompiledEnvSchema,
144+
>(def: D, config?: ArkEnvConfig & { safe?: false }): ArkenvOutput<T, D>;
116145
export function arkenv<const T extends SchemaShape>(
117-
def: EnvSchema<T> | CompiledEnvSchema,
118-
config?: ArkEnvConfig,
119-
): distill.Out<at.infer<T, $>> | InferType<typeof def>;
120-
export function arkenv<const T extends SchemaShape>(
121-
def: EnvSchema<T> | CompiledEnvSchema,
146+
def: EnvSchema<T>,
147+
config: ArkEnvConfig & { safe: true },
148+
): SafeArkEnvResult<distill.Out<at.infer<T, $>>>;
149+
export function arkenv<T extends CompiledEnvSchema>(
150+
def: T,
151+
config: ArkEnvConfig & { safe: true },
152+
): SafeArkEnvResult<InferType<T>>;
153+
export function arkenv<
154+
const T extends SchemaShape,
155+
const D extends EnvSchema<T> | CompiledEnvSchema,
156+
>(
157+
def: D,
158+
config: ArkEnvConfig & { safe: true },
159+
): SafeArkEnvResult<ArkenvOutput<T, D>>;
160+
export function arkenv<
161+
const T extends SchemaShape,
162+
const D extends EnvSchema<T> | CompiledEnvSchema,
163+
>(
164+
def: D,
122165
config: ArkEnvConfig = {},
123-
): distill.Out<at.infer<T, $>> | InferType<typeof def> {
166+
): ArkenvOutput<T, D> | SafeArkEnvResult<ArkenvOutput<T, D>> {
167+
if (config.safe) {
168+
return safeExecute(() => parse(def as any, config));
169+
}
124170
// biome-ignore lint/suspicious/noExplicitAny: parse handles both EnvSchema<T> and CompiledEnvSchema at runtime
125171
return parse(def as any, config);
126172
}

0 commit comments

Comments
 (0)