Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion apps/playgrounds/bun-react/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import arkenv from "@arkenv/bun-plugin";
const result = await Bun.build({
entrypoints: ["./src/index.html"],
outdir: "./dist",
sourcemap: true,
target: "browser",
minify: true,
plugins: [arkenv],
Expand Down
2 changes: 2 additions & 0 deletions apps/playgrounds/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from "zod";

const env = arkenv({
HOST: "string.host",
MY_STRING_AS_NUMBER: type("string").pipe((value) => Number(value)),
PORT: "number.port",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
ALLOWED_ORIGINS: type("string[]").default(() => []),
Expand All @@ -24,6 +25,7 @@ console.log({
nodeEnv,
allowedOrigins,
debug,
myString: env.MY_STRING_AS_NUMBER,
zedEnv,
});

Expand Down
2 changes: 2 additions & 0 deletions examples/basic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from "zod";

const env = arkenv({
HOST: "string.host",
MY_STRING_AS_NUMBER: type("string").pipe((value) => Number(value)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the coerce utility implementation
find . -name "coerce.ts" -o -name "coerce.js" | head -20

Repository: yamcodes/arkenv

Length of output: 90


🏁 Script executed:

#!/bin/bash
# Also check the types.ts to see available built-in types
cat packages/arkenv/src/types.ts 2>/dev/null | head -100

Repository: yamcodes/arkenv

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check create-env implementation to understand the recommended patterns
rg -n "coerce|pipe|Number\(" packages/arkenv/src/create-env.ts -A3 -B3

Repository: yamcodes/arkenv

Length of output: 794


🏁 Script executed:

#!/bin/bash
# Check if there are other examples using number coercion
rg -n "type.*pipe.*Number|\.pipe.*number" examples/ -A5 -B5

Repository: yamcodes/arkenv

Length of output: 2431


🏁 Script executed:

#!/bin/bash
# Check test files for how string->number coercion should work
rg -n "string.*number|Number.*isNaN" packages/arkenv/src/*.test.ts -A3 -B3

Repository: yamcodes/arkenv

Length of output: 643


🏁 Script executed:

#!/bin/bash
# Read the coerce.ts file
cat packages/arkenv/src/coerce.ts

Repository: yamcodes/arkenv

Length of output: 752


🏁 Script executed:

#!/bin/bash
# Check what the pipe validation does in ArkType - search for pipe error handling
rg -n "pipe.*issue|pipe.*error" packages/arkenv/src/ -A3 -B3

Repository: yamcodes/arkenv

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Understand the relationship between coerce and .pipe approaches
rg -n "\.pipe" packages/arkenv/src/ -B2 -A2

Repository: yamcodes/arkenv

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify when coerce runs relative to .pipe() transformations
# Check create-env.ts flow
sed -n '40,55p' packages/arkenv/src/create-env.ts

Repository: yamcodes/arkenv

Length of output: 671


🏁 Script executed:

#!/bin/bash
# Check if there are tests showing the expected behavior of .pipe() with NaN
rg -n "pipe.*Number|MY_STRING|coerce.*number" packages/arkenv/src/*.test.ts -B3 -A5

Repository: yamcodes/arkenv

Length of output: 2613


🏁 Script executed:

#!/bin/bash
# Verify the learning about Number.parseInt preference
rg -n "Number\.parseInt|useNumberNamespace" .cursor/rules/ -B2 -A2

Repository: yamcodes/arkenv

Length of output: 654


🏁 Script executed:

#!/bin/bash
# Check if "number" string type can be used in place of type("string").pipe(Number)
# Look at type definitions and tests
rg -n '"number"' packages/arkenv/src/coercion.integration.test.ts -B2 -A2 | head -40

Repository: yamcodes/arkenv

Length of output: 874


🏁 Script executed:

#!/bin/bash
# Verify what happens with invalid input to the current .pipe(Number) implementation
# Check if there are any guards in place
rg -n "pipe.*validate|pipe.*check" packages/arkenv/src/ -B3 -A3

Repository: yamcodes/arkenv

Length of output: 41


Use the built-in "number" type instead of custom Number() conversion.

Replace the manual .pipe((value) => Number(value)) with ArkType's built-in "number" type, which handles string-to-number coercion safely with proper NaN validation:

-	MY_STRING_AS_NUMBER: type("string").pipe((value) => Number(value)),
+	MY_STRING_AS_NUMBER: "number",

The built-in "number" type already coerces valid numeric strings (e.g., "3000"3000) and rejects invalid ones (e.g., "abc") during validation. This follows the guideline to leverage ArkType's built-in types where possible and keeps the schema readable and consistent with other fields like PORT: "number.port".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
MY_STRING_AS_NUMBER: type("string").pipe((value) => Number(value)),
MY_STRING_AS_NUMBER: "number",
🤖 Prompt for AI Agents
In examples/basic/index.ts around line 6, the field currently uses a custom pipe
to coerce strings to numbers (MY_STRING_AS_NUMBER: type("string").pipe((value)
=> Number(value))), replace this with ArkType's built-in "number" type (e.g.,
MY_STRING_AS_NUMBER: "number") so valid numeric strings are coerced and invalid
ones rejected; update the schema entry to use the built-in type for consistency
with other fields like PORT ("number.port") and remove the custom .pipe
conversion.

PORT: "number.port",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
ALLOWED_ORIGINS: type("string[]").default(() => []),
Expand All @@ -24,6 +25,7 @@ console.log({
nodeEnv,
allowedOrigins,
debug,
myString: env.MY_STRING_AS_NUMBER,
zedEnv,
});

Expand Down
165 changes: 158 additions & 7 deletions examples/basic/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion examples/with-bun-react/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import arkenv from "@arkenv/bun-plugin";
const result = await Bun.build({
entrypoints: ["./src/index.html"],
outdir: "./dist",
sourcemap: true,
target: "browser",
minify: true,
plugins: [arkenv],
Expand Down
66 changes: 66 additions & 0 deletions openspec/changes/add-coercion/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Design: Coercion

## Architecture
The coercion logic will be implemented as a preprocessing step within the `createEnv` function.

### Flow
1. **Input**: `createEnv` receives a schema definition (`def`) and an environment object (`env`).
2. **Inspection**: We inspect `def` to identify keys that expect primitive types (number, boolean) but will receive strings from `env`.
* This inspection primarily targets schema definitions provided as plain objects with string values (e.g., `{ PORT: "number" }`).
* Complex ArkType definitions (already compiled types) may be skipped or require advanced introspection (out of scope for initial implementation).
3. **Coercion**:
* For each identified key, we check the corresponding value in `env`.
* If the target type is `number` (or subtypes like `number.port`, `number.epoch`), we attempt to convert the string to a number using `Number()` or `parseFloat()`.
* If the target type is `boolean`, we convert "true" to `true` and "false" to `false`.
4. **Validation**: The modified `env` object (with coerced values) is passed to the ArkType schema for validation.

## Decisions

### Decision: Use Preprocessing for Coercion
We decided to implement coercion as a preprocessing step that runs *before* ArkType validation, rather than using ArkType's native "morphs" or scope-level overrides.

**Rationale:**
1. **Scope Limitations**: As confirmed by the ArkType creator, there is no mechanism to apply a morph to an entire scope (e.g., "all numbers"). We would have to manually override `number` and every subtype (`number.port`, `number.epoch`, etc.), which is brittle and unscalable.
Comment thread
yamcodes marked this conversation as resolved.
2. **Separation of Concerns**: Coercion (parsing a string into a primitive) is distinct from Validation (checking if that primitive meets criteria). Keeping coercion separate allows `arkenv` to handle the "environment variable boundary" explicitly, ensuring that `number` in the schema always validates a real JavaScript number.
3. **Complexity**: Implementing type-level mapping for global coercion would introduce significant complexity to the types, whereas a runtime preprocessor is straightforward and easier to maintain.

**Alternatives Considered:**
* **ArkType Morphs**: We considered using `type("string").pipe(...)` or overriding keywords in the scope. This was rejected because it requires manual per-type configuration or complex scope manipulation that doesn't propagate to sub-keywords.
* **Manual Parsing**: Continuing with the current state where users manually pipe string types. This was rejected as it degrades developer experience.

## Risks / Trade-offs
* **String Definitions**: This approach relies on inspecting the schema definition. It works best when users provide string definitions (e.g., `{ PORT: "number" }`). If a user provides a pre-compiled `type("number")`, we cannot easily inspect it to apply coercion, meaning those values might remain strings and fail validation. We will document this limitation.

## Implementation Details

### `coerce` Utility
We will create a utility function `coerce(def: Record<string, unknown>, env: Record<string, string | undefined>)` that returns a new environment object.

```typescript
function coerce(def: Record<string, unknown>, env: Record<string, string | undefined>) {
const coerced = { ...env };
for (const key in def) {
const typeDef = def[key];
if (typeof typeDef === "string") {
if (typeDef.startsWith("number")) {
// Coerce to number
} else if (typeDef === "boolean") {
// Coerce to boolean
}
}
}
return coerced;
}
```

### Integration
In `createEnv`:

```typescript
export function createEnv(def, env = process.env) {
// ...
const coercedEnv = isPlainObject(def) ? coerce(def, env) : env;
const validatedEnv = schema(coercedEnv);
// ...
}
```
28 changes: 28 additions & 0 deletions openspec/changes/add-coercion/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Coercion

## Problem
Environment variables are always strings at runtime, but users want to treat them as typed primitives without manual conversion.

**Current state:**
```typescript
// Manual conversion required
const env = arkenv({
PORT: type("string").pipe(str => Number.parseInt(str, 10)),
DEBUG: type("string").pipe(str => str === "true")
});
```

**Desired state:**
```typescript
// Coercion
const env = arkenv({
PORT: "number", // "3000" → 3000
DEBUG: "boolean", // "true" → true
TIMESTAMP: "number.epoch" // "1640995200000" → 1640995200000
Comment thread
yamcodes marked this conversation as resolved.
});
```

## Solution
Implement an automatic coercion layer in `arkenv` that runs before ArkType validation. This layer will inspect the provided schema definition and, where possible, convert string environment variables into their target primitive types (number, boolean) so that ArkType can validate them as such.

This approach allows `arkenv` to support "native" feeling environment variables while leveraging ArkType's powerful validation for the final values.
42 changes: 42 additions & 0 deletions openspec/changes/add-coercion/specs/coercion/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Spec: Coercion

## ADDED Requirements

### Requirement: Coerce numeric strings to numbers
The system MUST coerce environment variable strings to numbers when the schema definition specifies `number` or a `number.*` subtype.

#### Scenario: Basic number coercion
Given a schema `{ PORT: "number" }`
And an environment `{ PORT: "3000" }`
When `arkenv` parses the environment
Then the result should contain `PORT` as the number `3000`

#### Scenario: Number subtype coercion
Given a schema `{ TIMESTAMP: "number.epoch" }`
And an environment `{ TIMESTAMP: "1640995200000" }`
When `arkenv` parses the environment
Then the result should contain `TIMESTAMP` as the number `1640995200000`
Comment thread
yamcodes marked this conversation as resolved.

### Requirement: Coerce boolean strings to booleans
The system MUST coerce environment variable strings "true" and "false" to boolean values when the schema definition specifies `boolean`.

#### Scenario: Boolean true coercion
Given a schema `{ DEBUG: "boolean" }`
And an environment `{ DEBUG: "true" }`
When `arkenv` parses the environment
Then the result should contain `DEBUG` as the boolean `true`

#### Scenario: Boolean false coercion
Given a schema `{ DEBUG: "boolean" }`
And an environment `{ DEBUG: "false" }`
When `arkenv` parses the environment
Then the result should contain `DEBUG` as the boolean `false`

### Requirement: Pass through non-coercible values
The system MUST pass through values unchanged if they do not match a coercible type definition or if coercion fails (letting ArkType handle the validation error).

#### Scenario: String pass-through
Given a schema `{ API_KEY: "string" }`
And an environment `{ API_KEY: "12345" }`
When `arkenv` parses the environment
Then the result should contain `API_KEY` as the string `"12345"`
Loading
Loading