Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 8 additions & 5 deletions openspec/changes/coercion-public-api/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

The secondary `coerce` function will no longer attempt to reach into the `BaseRoot` instances or use `.transform()`. Instead, it will leverage ArkType's public `.pipe()` functionality to create a data transformation layer.

### 1. Introspection via `schema.in.json`
### 1. Introspection via `schema.in.toJsonSchema()`

We use `schema.in` to get a representation of the schema's input *without morphs*. We then access `.json` to get a serializable structure.
We use `schema.in` to get a representation of the schema's input *without morphs* and then call `.toJsonSchema()` to get a standard JSON Schema representation for traversal. This ensures compatibility with schemas that use `.pipe()` or other morphs, which would otherwise cause `toJsonSchema()` to throw.

**Key identification rules (mapped from current `isNumeric`/`isBoolean`):**
- **Numeric**: `domain: "number"`, or `kind: "unit"` with a number value, or an `intersection` with a numeric basis.
Expand Down Expand Up @@ -38,9 +38,12 @@ type("unknown")

## Trade-offs and Considerations

### Why `in.json` over `toJsonSchema()`?
1. **Fidelity**: `in.json` is a 1:1 representation of ArkType's internal state but in a public, serializable format. `toJsonSchema` is lossy and handles things like `bigint` or customs constraints via fallbacks.
2. **Complexity**: `toJsonSchema` introduces `$ref` and `$defs`, which would require a complex resolver to traverse. `in.json` keeps references local to the object or uses stable aliases.
### Why `toJsonSchema()` over `in.json`?
1. **Standardization**: `toJsonSchema()` returns a Draft 2020-12 compliant structure, making the introspection logic decoupled from ArkType's internal `JsonStructure`.
2. **Type Safety**: The `JsonSchema` type provided by ArkType is exhaustive and strictly typed, whereas `in.json` returns a loose object.

### Why `.in.toJsonSchema()`?
ArkType's `toJsonSchema()` implementation throws a `ToJsonSchemaError` if the schema contains morphs. By accessing `.in` first, we resolve the input side of the root node (which is always morph-free) and generate a schema representing what the environment variables must look like before transformations.

### Performance
Introspection is performed once per `coerce()` call. Since `createEnv` usually runs once at startup, this is negligible. The resulting morph is a simple iteration over known paths.
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/coercion-public-api/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The original coercion implementation relied on undocumented ArkType internal API
Switch to a **Schema-Directed Coercion** approach.

Instead of inspecting proprietary ArkType structures (`schema.in.json`) or mutating internals, we will:
1. Introspect the schema's input requirements using the **standard** `schema.toJsonSchema()` API. This provides a strictly typed, version-controlled JSON Schema (Draft 2020-12).
1. Introspect the schema's input requirements using the **standard** `schema.in.toJsonSchema()` API. This provides a strictly typed, version-controlled JSON Schema (Draft 2020-12) of the schema's input side, ensuring compatibility even when the schema contains morphs.
2. Identify paths that expect `number` or `boolean` types by traversing standard JSON Schema fields (`type`, `anyOf`, `const`, `enum`).
3. Pre-process the input data (environment variables) to coerce values at those paths *before* passing the data to ArkType for final validation.
4. Wrap the original schema in a pipeline: `type("unknown").pipe(applyCoercion).pipe(schema)`.
Expand All @@ -30,6 +30,6 @@ Instead of inspecting proprietary ArkType structures (`schema.in.json`) or mutat
- **Internal Sharing**: Any logic shared across packages must reside in `packages/internal/`.

### Strict Type Safety
- **Standard API only**: Use `schema.toJsonSchema()` for all introspection.
- **Standard API only**: Use `schema.in.toJsonSchema()` for all introspection.
- **No Prop probing**: Do not probe for `domain`, `unit`, or `branches` on generic objects.
- **Avoid Assertions**: Use discriminated unions provided by the `JsonSchema` type definition.
10 changes: 10 additions & 0 deletions openspec/specs/coercion/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,13 @@ And an environment `{ VERSION: "1" }`
When `arkenv` parses the environment
Then it should return a validation error (string "1" is not number 1)

### Requirement: Support schemas with morphs (pipes)
The system MUST support schemas that contain ArkType morphs (pipes). Coercion should logic should only inspect the input side of the schema to avoid errors.

#### Scenario: Coercion with manual morph
Given a schema `{ PORT: "number", MANUAL: type("string").pipe(Number) }`
And an environment `{ PORT: "3000", MANUAL: "456" }`
When `arkenv` parses the environment
Then the result should contain `PORT` as number `3000`
And `MANUAL` as number `456`

17 changes: 17 additions & 0 deletions packages/arkenv/src/coercion.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,21 @@ describe("coercion integration", () => {
expect(() => createEnv({ DEBUG: "boolean" }, { DEBUG: "yes" })).toThrow();
expect(() => createEnv({ DEBUG: "boolean" }, { DEBUG: "1" })).toThrow();
});

it("should work with schemas containing morphs", () => {
const Env = type({
PORT: "number.port",
VITE_MY_NUMBER_MANUAL: type("string").pipe((str) =>
Number.parseInt(str, 10),
),
});

const env = createEnv(Env, {
PORT: "3000",
VITE_MY_NUMBER_MANUAL: "456",
});

expect(env.PORT).toBe(3000);
expect(env.VITE_MY_NUMBER_MANUAL).toBe(456);
});
});
17 changes: 17 additions & 0 deletions packages/arkenv/src/create-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ describe("createEnv", () => {
const schema = { VAL: "1 | 2" } as const;
expect(createEnv(schema, { VAL: "1" }).VAL).toBe(1);
});

it("should work with schemas containing morphs", () => {
const Env = type({
PORT: "number.port",
VITE_MY_NUMBER_MANUAL: type("string").pipe((str) =>
Number.parseInt(str, 10),
),
});

const env = createEnv(Env, {
PORT: "3000",
VITE_MY_NUMBER_MANUAL: "456",
});

expect(env.PORT).toBe(3000);
expect(env.VITE_MY_NUMBER_MANUAL).toBe(456);
});
});

it("should validate string env variables", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/arkenv/src/utils/coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,22 @@ describe("coerce", () => {
},
});
});

it("should work with schemas containing morphs", () => {
const schema = type({
PORT: "number",
VITE_MY_NUMBER_MANUAL: type("string").pipe((str) =>
Number.parseInt(str, 10),
),
});
const coercedSchema = coerce(schema);
const result = coercedSchema({
PORT: "3000",
VITE_MY_NUMBER_MANUAL: "456",
});
expect(result).toEqual({
PORT: 3000,
VITE_MY_NUMBER_MANUAL: 456,
});
});
});
2 changes: 1 addition & 1 deletion packages/arkenv/src/utils/coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ const applyCoercion = (data: unknown, targets: CoercionTarget[]) => {
* before validation.
*/
export function coerce<t, $ = {}>(schema: BaseType<t, $>): BaseType<t, $> {
const json = schema.toJsonSchema();
const json = schema.in.toJsonSchema();
const targets = findCoercionPaths(json);

if (targets.length === 0) {
Expand Down