Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
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
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
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.
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
});
```

## 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`

### 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 on lines +62 to +75
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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);
});

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
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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;
}
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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);
});

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
Copy link

Copilot AI Nov 29, 2025

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
Copy link

Copilot AI Nov 29, 2025

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, $>> | 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
Loading
Loading