Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
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
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"`
7 changes: 7 additions & 0 deletions openspec/changes/add-coercion/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Tasks

- [x] Implement `coerce` utility function in `src/utils.ts` or `src/coerce.ts`
- [x] Integrate `coerce` into `createEnv` in `src/create-env.ts`
- [x] Add unit tests for `coerce` logic
- [x] Add integration tests in `tests/coercion.test.ts` verifying `number`, `boolean`, and sub-keywords
- [x] Update documentation to explain coercion behavior and limitations
15 changes: 15 additions & 0 deletions packages/arkenv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ ArkEnvError: Errors found while validating environment variables
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
Comment thread
yamcodes marked this conversation as resolved.
});
```
Comment on lines +62 to +75

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

The coercion documentation should clarify an important limitation: coercion only works with raw schema objects (e.g., arkenv({ PORT: "number" })), not with pre-compiled types (e.g., arkenv(type({ PORT: "number" }))). This is mentioned in the integration test but should be prominently documented here.

Copilot uses AI. Check for mistakes.

## Features

- Zero external dependencies
Expand Down
68 changes: 68 additions & 0 deletions packages/arkenv/src/coerce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { coerce } from "./coerce";

describe("coerce", () => {
it("should coerce number strings", () => {
const def = { PORT: "number" };
const env = { PORT: "3000" };
const result = coerce(def, env);
expect(result.PORT).toBe(3000);
});
Comment on lines +5 to +10

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

Missing test coverage for edge cases in number coercion:

  • Empty strings: Number("") returns 0
  • Whitespace-only strings: Number(" ") returns 0
  • Special number strings like "Infinity", "-Infinity", "NaN"

Consider adding tests to verify the expected behavior for these cases.

Copilot uses AI. Check for mistakes.

it("should coerce number subtypes", () => {
const def = { TIMESTAMP: "number.epoch" };
const env = { TIMESTAMP: "1640995200000" };
const result = coerce(def, env);
expect(result.TIMESTAMP).toBe(1640995200000);
Comment thread
yamcodes marked this conversation as resolved.
});

it("should coerce boolean 'true'", () => {
const def = { DEBUG: "boolean" };
const env = { DEBUG: "true" };
const result = coerce(def, env);
expect(result.DEBUG).toBe(true);
});

it("should coerce boolean 'false'", () => {
const def = { DEBUG: "boolean" };
const env = { DEBUG: "false" };
const result = coerce(def, env);
expect(result.DEBUG).toBe(false);
});
Comment on lines +19 to +31

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

Missing test coverage for additional boolean edge cases:

  • Case sensitivity: What happens with "True", "FALSE", "TRUE"?
  • Numeric booleans: "0", "1"
  • Other truthy/falsy strings commonly used in env vars: "yes", "no", "on", "off"

While the current implementation only accepts exact "true"/"false", tests should verify these other values are properly rejected or document why only these two values are supported.

Copilot uses AI. Check for mistakes.

it("should pass through non-coercible values", () => {
const def = { API_KEY: "string" };
const env = { API_KEY: "12345" };
const result = coerce(def, env);
expect(result.API_KEY).toBe("12345");
});

it("should pass through values that fail number coercion", () => {
const def = { PORT: "number" };
const env = { PORT: "not-a-number" };
const result = coerce(def, env);
expect(result.PORT).toBe("not-a-number");
});

it("should pass through values that fail boolean coercion", () => {
const def = { DEBUG: "boolean" };
const env = { DEBUG: "yes" };
const result = coerce(def, env);
expect(result.DEBUG).toBe("yes");
});

it("should handle undefined values", () => {
const def = { PORT: "number" };
const env = { PORT: undefined };
const result = coerce(def, env);
expect(result.PORT).toBeUndefined();
});

it("should ignore keys not in definition", () => {
const def = { PORT: "number" };
const env = { PORT: "3000", EXTRA: "foo" };
const result = coerce(def, env);
expect(result.PORT).toBe(3000);
expect(result.EXTRA).toBe("foo");
});
});
Comment on lines +61 to +68

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] Add test coverage for coercion with ArkType modifiers in raw schema definitions:

  • Optional types: { PORT: "number?" } with missing value
  • Types with pipe operators: { VALUE: "number | string" } to ensure it doesn't incorrectly coerce strings that are valid
  • Union types with boolean: { FLAG: "boolean | string" }

The current string-based detection (typeDef.startsWith("number")) might have unintended behavior with these patterns.

Copilot uses AI. Check for mistakes.
31 changes: 31 additions & 0 deletions packages/arkenv/src/coerce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function coerce(
def: Record<string, unknown>,
env: Record<string, string | undefined>,
): Record<string, unknown> {
const coerced: Record<string, unknown> = { ...env };
Comment on lines +4 to +5

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] The return type Record<string, unknown> doesn't match the input parameter constraint. The function accepts env: Record<string, string | undefined> but returns Record<string, unknown>, which correctly reflects that values can be coerced to numbers/booleans. However, for better type safety, consider making the return type more explicit: Record<string, string | number | boolean | undefined> to document exactly what types can be returned.

Suggested change
): Record<string, unknown> {
const coerced: Record<string, unknown> = { ...env };
): Record<string, string | number | boolean | undefined> {
const coerced: Record<string, string | number | boolean | undefined> = { ...env };

Copilot uses AI. Check for mistakes.

for (const [key, typeDef] of Object.entries(def)) {
const value = env[key];

if (typeof value === "undefined") {
continue;
}

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] After checking for undefined, the code should verify that value is actually a string before calling string methods. While RuntimeEnvironment type suggests values are string | undefined, defensive programming would add: if (typeof value !== "string") continue; after line 12 to prevent runtime errors if non-string values are passed.

Suggested change
}
}
if (typeof value !== "string") {
continue;
}

Copilot uses AI. Check for mistakes.

if (typeof typeDef === "string") {
if (typeDef.startsWith("number")) {
const asNumber = Number(value);
if (!Number.isNaN(asNumber)) {
coerced[key] = asNumber;
}
Comment on lines +15 to +19

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

Using Number(value) for coercion can produce unexpected results for edge cases:

  • Empty strings: Number("") returns 0, which may not be the intended behavior
  • Whitespace: Number(" ") returns 0
  • Scientific notation: Number("1e3") returns 1000 (might be acceptable)

Consider using Number.parseFloat() or adding explicit validation to reject empty/whitespace-only strings to avoid silent conversion of invalid inputs to 0.

Copilot uses AI. Check for mistakes.
} else if (typeDef === "boolean") {
if (value === "true") {
coerced[key] = true;
} else if (value === "false") {
coerced[key] = false;
}
}
}
Comment on lines +14 to +27

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

The coercion logic only handles simple string type definitions. It doesn't account for:

  1. Union types: "number | string" - will try to coerce even though string is acceptable
  2. Optional types: "number?" - will coerce, but the ? suffix might not be handled
  3. Types with defaults: "number = 3000" - will coerce, but the = 3000 suffix needs testing

Consider adding logic to parse these modifiers or adding tests to verify current behavior with these patterns.

Copilot uses AI. Check for mistakes.
}

return coerced;
}
62 changes: 62 additions & 0 deletions packages/arkenv/src/coercion.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { createEnv } from "./create-env";
import { type } from "./index";

describe("coercion integration", () => {
it("should coerce and validate numbers", () => {
const env = createEnv({ PORT: "number" }, { PORT: "3000" });
expect(env.PORT).toBe(3000);
expect(typeof env.PORT).toBe("number");
});

it("should coerce and validate booleans", () => {
const env = createEnv(
{ DEBUG: "boolean", VERBOSE: "boolean" },
{ DEBUG: "true", VERBOSE: "false" },
);
expect(env.DEBUG).toBe(true);
expect(env.VERBOSE).toBe(false);
});

it("should coerce and validate number subtypes (port)", () => {
const env = createEnv({ PORT: "number.port" }, { PORT: "8080" });
expect(env.PORT).toBe(8080);
});
Comment thread
yamcodes marked this conversation as resolved.

it("should fail validation if coercion fails (not a number)", () => {
expect(() => createEnv({ PORT: "number" }, { PORT: "abc" })).toThrow();
});

it("should fail validation if value is valid number but invalid subtype", () => {
expect(() =>
createEnv(
{ PORT: "number.port" },
{ PORT: "99999" }, // Too large for port
),
).toThrow();
});

it("should work with mixed coerced and non-coerced values", () => {
const env = createEnv(
{
PORT: "number",
HOST: "string",
DEBUG: "boolean",
},
{
PORT: "3000",
HOST: "localhost",
DEBUG: "true",
},
);
expect(env.PORT).toBe(3000);
expect(env.HOST).toBe("localhost");
expect(env.DEBUG).toBe(true);
});
Comment on lines +39 to +55

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] Missing test coverage for the case where a non-string primitive (e.g., actual number or boolean) is passed in the environment object. While typically environment variables are strings, the function signature accepts Record<string, string | undefined>, and in test scenarios someone might pass actual primitives. Add a test to verify that already-coerced values (numbers, booleans) are passed through unchanged.

Copilot uses AI. Check for mistakes.

it("should NOT coerce if using compiled types", () => {
// This documents the limitation
const schema = type({ PORT: "number" });
expect(() => createEnv(schema, { PORT: "3000" })).toThrow(); // "3000" is a string, schema expects number, no coercion happens
});
});
Comment on lines +57 to +62

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] Missing test coverage for coercion with default values. Consider adding a test to verify that:

  1. Coercion works when a value is provided (e.g., PORT: "number = 3000" with env PORT: "8080" should result in 8080)
  2. Default value is used when environment variable is missing (e.g., PORT: "number = 3000" with empty env should result in 3000)
  3. The default value type is handled correctly (defaults are usually not strings in the schema)

Copilot uses AI. Check for mistakes.
15 changes: 10 additions & 5 deletions packages/arkenv/src/create-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { $ } from "@repo/scope";
import type { EnvSchemaWithType, InferType, SchemaShape } from "@repo/types";
import type { type as at, distill } from "arktype";
import { coerce } from "./coerce";
import { ArkEnvError } from "./errors";
import { type } from "./type";

Expand Down Expand Up @@ -38,12 +39,16 @@ export function createEnv<const T extends SchemaShape>(
): distill.Out<at.infer<T, (typeof $)["t"]>> | InferType<typeof def> {
// If def is a type definition (has assert method), use it directly
// Otherwise, use raw() to convert the schema definition
const schema =
typeof def === "function" && "assert" in def
? def
: $.type.raw(def as EnvSchema<T>);
const isCompiledType = typeof def === "function" && "assert" in def;
const schema = isCompiledType ? def : $.type.raw(def as EnvSchema<T>);

const validatedEnv = schema(env);
// Coerce values if we have a raw definition
// We can't easily inspect compiled types to know which fields to coerce
const coercedEnv = !isCompiledType
? coerce(def as Record<string, unknown>, env)
: env;

const validatedEnv = schema(coercedEnv);

if (validatedEnv instanceof type.errors) {
throw new ArkEnvError(validatedEnv);
Expand Down
4 changes: 2 additions & 2 deletions packages/internal/keywords/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { type } from "arktype";
/**
* A `string` that can be parsed into a number between 0 and 65535

Copilot AI Nov 29, 2025

Copy link

Choose a reason for hiding this comment

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

The documentation comment is outdated. It says "A string that can be parsed into a number" but the type now accepts both string | number due to the coercion feature. Consider updating to: "A string or number between 0 and 65535"

Suggested change
* A `string` that can be parsed into a number between 0 and 65535
* A string or number between 0 and 65535

Copilot uses AI. Check for mistakes.
*/
export const port = type("string", "=>", (data, ctx) => {
const asNumber = Number.parseInt(data, 10);
export const port = type("string | number", "=>", (data, ctx) => {
const asNumber = typeof data === "string" ? Number.parseInt(data, 10) : data;
const isInteger = Number.isInteger(asNumber);
const isBetween = 0 <= asNumber && asNumber <= 65535;
if (!isInteger || !isBetween) {
Expand Down
Loading