Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
74e426d
feat: Enhance documentation with new components and content, update s…
yamcodes Dec 20, 2025
b4f1a35
refactor: improve `coerce` utility with explicit root-level primitive…
yamcodes Dec 20, 2025
c564f9e
feat: Introduce `parsedNumber` and `parsedBoolean` types, refactor `p…
yamcodes Dec 20, 2025
23cdd76
feat: add `parsedNumber` keyword, rename `boolean` to `parsedBoolean`…
yamcodes Dec 20, 2025
d12e074
test: add test case for strict number literals coercion
yamcodes Dec 20, 2025
5fbabe4
feat: Implement string-to-number/boolean coercion via global schema t…
yamcodes Dec 20, 2025
8cfcbf9
docs: rename problem and solution sections to why and what changes
yamcodes Dec 20, 2025
b9f9c40
feat: add specification for environment variable type coercion, inclu…
yamcodes Dec 20, 2025
742a121
docs: clarify coercion specification purpose and remove an unused imp…
yamcodes Dec 20, 2025
6812925
feat: Simplify `port` keyword to accept only number integers and remo…
yamcodes Dec 21, 2025
82ccd6b
fix: Improve type coercion for numeric and boolean values from string…
yamcodes Dec 21, 2025
d779ac7
feat: Simplify `port` and `boolean` type handling by removing implici…
yamcodes Dec 21, 2025
471cce5
test: update boolean and number.port type assertions to use primitive…
yamcodes Dec 21, 2025
74fab46
feat: add comprehensive coercion tests for `createEnv`, including num…
yamcodes Dec 21, 2025
fb67bdb
feat: Enable automatic string coercion for number and boolean literal…
yamcodes Dec 21, 2025
f7cc6d4
fix: Prevent empty or whitespace strings from coercing to numbers and…
yamcodes Dec 21, 2025
7bfafd3
docs: Reformat coercion spec scenarios to use concise WHEN/THEN syntax.
yamcodes Dec 21, 2025
0e18d76
feat: Implement 'loose' coercion using `maybeParsed` morphs and docum…
yamcodes Dec 21, 2025
dfd847a
refactor: enhance `coerce` utility with ArkType internal typing and a…
yamcodes Dec 21, 2025
b4cf3f2
feat: Add ArkType compatibility workflow and contract tests, and enha…
yamcodes Dec 21, 2025
e1b4a96
feat: Add internal ArkType compatibility checks to coercion logic, re…
yamcodes Dec 21, 2025
af0d408
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 21, 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
34 changes: 18 additions & 16 deletions .changeset/cyan-loops-hear.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
"arkenv": minor
---

#### Native Coercion Support
#### Coercion

Added native support for coercion in the `createEnv` (`arkenv`) and `type` functions. In practice, this adds automatic type conversions for the `number` keyword (and its sub-keywords).

- **BREAKING**: ArkEnv now uses a scope that enables automatic string coercion for `number` and `boolean`. This means `number` is now a Morph type, which does not support ranges, divisors, and number literals with coercion. See "Known Limitations" for workarounds.
Added coercion in the `createEnv` (`arkenv`) functions for `number` and `boolean` types. Since we had a custom `boolean` keyword prior to this change, in practice, **this adds automatic type conversions for the `number` keyword (and its sub-keywords).**

Now, you can define a `number` directly:

```ts
const env = arkenv({
PORT: "number",
BOOLEAN: "boolean",
RANGE: "0 <= number <= 18",
EPOCH: "number.epoch",
});
```
Expand All @@ -21,6 +21,7 @@ const env = arkenv({
PORT=3000
EPOCH=1678886400000
BOOLEAN=true
RANGE=18
```

and it will be coerced to the desired types.
Expand All @@ -29,21 +30,22 @@ and it will be coerced to the desired types.
env.PORT // 3000
env.EPOCH // 1678886400000
env.BOOLEAN // true
env.RANGE // 18
```

#### Known Limitations
- Applying range bounds (e.g., `type("0 <= number <= 100")`) or divisors (e.g., `type("number % 5")`) directly to the coerced `number` keyword is currently not supported and will throw a `ParseError` due to how these constraints interact with Morphs.
- **Workaround**: Chain a constrained type using standard `arktype` definitions:
```ts
import { type as at } from "arktype";
* **BREAKING**: Our custom `boolean` keyword (which included parsing) has been removed, and ArkEnv now uses the standard ArkType `boolean` definition. For most use cases (through `createEnv` / `arkenv`), this should not make a difference, since string -> boolean coercion is still done (at the global level).

// Validation with bounds
const port = type("number").to(at("0 <= number <= 65535"));
* **BREAKING**: The `number.port` keyword has been simplified and no longer automatically parses strings to numbers. For most use cases (through `createEnv` / `arkenv`), this should not make a difference, since string -> number coercion is still done (at the global level).

// Validation with divisors
const div = type("number").to(at("number % 5"));
```ts
const env = arkenv({
PORT: "number.port",
});

// Coercion to specific literals (accepts "1" -> 1)
const onOff = type("number").to(at("0 | 1"));
env.PORT // 3000 (number)
```
- **Note**: Strict number literals (e.g., `type("1 | 2")`) continue to work as expected but will **not** coerce strings by default. To coerce strings to literals, use the workaround above.

```dotenv
PORT="3000"
```

7 changes: 7 additions & 0 deletions .changeset/few-rabbits-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@repo/keywords": patch
---

#### Added `parsedNumber` keyword

Added `parsedNumber` keyword to support parsing numeric values from strings.
6 changes: 4 additions & 2 deletions .changeset/humble-lizards-judge.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"@repo/keywords": minor
---

#### Removed `boolean` keyword
#### Renamed `boolean` keyword to `parsedBoolean`

**BREAKING**: The `boolean` keyword has been removed. Use the standard strict `boolean` keyword or the new scope-based coercion in `arkenv` instead.
Rename our `boolean` keyword to `parsedBoolean` to better reflect its purpose. It is still a morph that keeps a boolean a boolean, and parses "true" and "false" to booleans.

**BREAKING**: The `boolean` keyword has been renamed to `parsedBoolean`.
12 changes: 12 additions & 0 deletions .changeset/open-spiders-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@repo/scope": minor
---

#### Simplify `number.port` and remove `boolean` from custom scope

* **BREAKING**: The `boolean` keyword has been removed from the root scope. ArkEnv now uses the standard ArkType `boolean` definition and applies coercion globally.
* The `number.port` keyword has been simplified to handle numbers only, since coercion is now handled at the global level:

```ts
type("0 <= number.integer <= 65535")
```
9 changes: 0 additions & 9 deletions .changeset/plain-webs-dig.md

This file was deleted.

6 changes: 6 additions & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
"packageRules": [
// Note: Order by least important to most important,
// see note at the end of https://docs.renovatebot.com/configuration-options/#packagerules
{
"groupName": "ArkType",
"matchPackageNames": ["arktype", "@ark/**"],
"automerge": true,
"automergeType": "pr"
},

// By SemVer
{
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ pnpm-debug.log*
coverage/

# size-limit
esbuild-why*.html
esbuild-why*.html

# arktype clone
arktype
46 changes: 0 additions & 46 deletions openspec/changes/add-coercion/design.md

This file was deleted.

28 changes: 0 additions & 28 deletions openspec/changes/add-coercion/proposal.md

This file was deleted.

42 changes: 0 additions & 42 deletions openspec/changes/add-coercion/specs/coercion/spec.md

This file was deleted.

8 changes: 0 additions & 8 deletions openspec/changes/add-coercion/tasks.md

This file was deleted.

83 changes: 83 additions & 0 deletions openspec/changes/archive/2025-12-20-add-coercion/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Design: Coercion

## Architecture

Coercion is implemented via a post-parsing transformation of the ArkType schema.

### Flow
1. **Scope Definition**: `@repo/scope` defines `number` and `boolean` as standard ArkType keywords. This allows the string parser to handle refinements like `0 < number < 100` normally.
2. **Schema Compilation**: `createEnv` parses the user's schema definition against the scope.
3. **Global Transformation**: `createEnv` calls a `coerce` utility which uses `schema.transform()` to walk the nodes.
- If a node is numeric (a `number` domain or an intersection with a `number` basis), it is piped into `parsedNumber`.
- If a node is boolean, it is piped into `parsedBoolean`.
4. **Validation**: The resulting transformed schema is used to validate the environment. Since the leaf nodes are now morphs, they automatically coerce strings to their target types before validating constraints.

## Decisions

### Decision: Prefer Transformation over Scope Overrides
We initially attempted to override `number` and `boolean` directly in the scope with morphs. We rejected this because it broke ArkType's ability to apply numeric constraints (ranges, divisors) to those types.

**Rationale:**
* **Feature Completeness**: Supports ranges (`number >= 18`), divisors (`number % 2`), and unions seamlessly.
* **Parser Compatibility**: Keeps the scope primitives "clean," avoiding `ParseError` during schema definition.
* **Centralized Logic**: The conversion logic is isolated in a transformer, making it easier to debug and maintain.

### Decision: Relocate Coercion Primitives to Keywords
Conversion morphs like `parsedNumber` and `parsedBoolean` are kept in `@repo/keywords`.

**Rationale:**
* **Building Blocks**: These can be reused to build other types (like `port`) that need string-to-number parsing.
* **Modularity**: Keeps the `arkenv` transformer decoupled from the specific implementation of the conversion logic.

## Implementation Details

### `coerce` Utility (`packages/arkenv/src/utils/coerce.ts`)
The transformer identifies property-level values or root-level primitives and wraps them in morphs.

```typescript
const isNumeric = (node: any): boolean =>
node.domain === "number" ||
(node.hasKind?.("intersection") && node.basis?.domain === "number") ||
(node.hasKind?.("union") && node.branches.some(isNumeric)) ||
(node.kind === "unit" && typeof node.unit === "number");

const isBoolean = (node: any): boolean =>
node.domain === "boolean" ||
node.expression === "boolean" ||
(node.hasKind?.("union") && node.branches.some(isBoolean)) ||
(node.kind === "unit" && typeof node.unit === "boolean");

export function coerce(schema: any): any {
return schema.transform((kind, inner) => {
if (kind === "required" || kind === "optional") {
const value = inner.value;
if (isNumeric(value)) return { ...inner, value: maybeParsedNumber.pipe(value) };
if (isBoolean(value)) return { ...inner, value: maybeParsedBoolean.pipe(value) };
}
return inner;
});
}
```

### `@repo/keywords`
Provides the `maybeParsedNumber` and `maybeParsedBoolean` types used as the targets for the transformer's `pipe` operations. These "loose" morphs ensure that if a string cannot be parsed as a number/boolean, it returns the original string, allowing other branches of a union (like `string`) to match.

## Edge Cases and Scope Limitations

### Nested Objects
The `schema.transform()` method recursively traverses the schema. Since the transformer identifies and modifies `required` and `optional` property nodes, nested object structures are automatically handled as the walker reaches the leaf property definitions.

### Union Types
The `isNumeric` and `isBoolean` helpers recursively check union branches. If any branch matches the target domain (or is an intersection/unit thereof), the entire property is wrapped in a coercion pipe.
- **Selective Coercion**: We use "loose" morphs (`maybeParsedNumber`) so that if the environment variable is `"hello"` and the type is `number | "hello"`, the morph returns `"hello"`, which then correctly matches the literal branch.

### Array Types
In the current implementation, coercion applies to **object properties** but does not automatically walk into **array elements** (sequences) unless they are defined as properties within an object. Root-level sequences or elements of a `number[]` are currently out of scope for automatic string-to-number coercion unless the environment variable is pre-processed into an array.

### Conditional/Discriminated Types
Discriminated unions are handled similarly to standard unions. As long as the property node itself can be identified as numeric or boolean (or a union containing them), the coercion will be applied. Narrowing logic within ArkType happens *after* the morph has attempted to produce a numeric value.

### Scope Limitations
- **Internal API Reliance**: This implementation relies on undocumented ArkType internal structures.
- **Literal Strictness**: Per requirements, numeric literals like `1 | 2` are specifically identified via unit checks to ensure they are coerced from `"1"`, aligned with standard environment variable behavior where all inputs start as strings.
- **Whitespace**: Empty strings or whitespace are currently NOT coerced to `0` for numbers; they are preserved as strings, which will typically fail numeric validation as intended.
15 changes: 15 additions & 0 deletions openspec/changes/archive/2025-12-20-add-coercion/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Coercion

## Why
Environment variables are always strings at runtime, but users want to treat them as typed primitives (like `number` or `boolean`) without manual conversion.

While ArkType supports "morphs" for transformation, applying them directly to base keywords like `number` prevents the use of refinements (e.g., `number >= 18` or `number % 2`) because ArkType's parser cannot apply numeric constraints to non-numeric nodes (morphs).

## What Changes
We keep the core primitives clean and apply a **Global Schema Transformer** during environment parsing in `arkenv`.

1. **Keywords**: Define `parsedNumber` and `parsedBoolean` in `@repo/keywords` as reusable building blocks that handle string-to-primitive conversion.
2. **Scope**: Keep `number` and `boolean` as standard ArkType types in `@repo/scope` so they remain "constrainable" (supporting ranges, divisors, etc.).
3. **Transformation**: In `createEnv`, use ArkType's `schema.transform()` to walk the fully parsed schema. The transformer identifies numeric and boolean leaf nodes and automatically wraps them in the appropriate coercion morph.

This approach provides full coercion support for environment variables while preserving ArkType's ability to validate ranges, divisors, and other numeric constraints.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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
**WHEN** a schema `{ PORT: "number" }` is parsed with environment `{ PORT: "3000" }`
**THEN** the result should contain `PORT` as the number `3000`

### Requirement: Support numeric refinements
The system MUST support numeric refinements (ranges, divisors) on coerced environment variables.

#### Scenario: Range coercion
**WHEN** a schema `{ AGE: "number >= 18" }` is parsed with environment `{ AGE: "21" }`
**THEN** the result should contain `AGE` as the number `21`

#### Scenario: Range failure
**WHEN** a schema `{ AGE: "number >= 18" }` is parsed with environment `{ AGE: "15" }`
**THEN** it should return a validation error indicating `AGE` must be at least 18

#### Scenario: Divisor coercion
**WHEN** a schema `{ EVEN: "number % 2" }` is parsed with environment `{ EVEN: "4" }`
**THEN** the result should contain `EVEN` as the number `4`

### 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 coercion
**WHEN** a schema `{ DEBUG: "boolean" }` is parsed with environment `{ DEBUG: "true" }`
**THEN** the result should contain `DEBUG` as the boolean `true`

### Requirement: Strictness by default for literals
The system MUST NOT coerce strings to numbers for literal types unless explicitly specified, preserving standard ArkType strictness.

#### Scenario: Literal strictness
**WHEN** a schema `{ VERSION: "1 | 2" }` is parsed with environment `{ VERSION: "1" }`
**THEN** it should return a validation error (string "1" is not number 1)
Loading
Loading