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
7 changes: 7 additions & 0 deletions .changeset/better-crews-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"arkenv": patch
---

#### Fix Standard Schema type inference

Fixed type inference when using `validator: "standard"` mode. The `env` object now correctly infers types from Standard Schema validators (Zod, Valibot, etc.) instead of wrapping them in ArkType-specific types like `distill.Out`.
111 changes: 111 additions & 0 deletions openspec/changes/fix-standard-mode-inference/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Design: Standard Mode Type Inference

## Context
`createEnv` is currently defined with several overloads. The most common one is:

```ts
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T>,
config?: ArkEnvConfig,
): distill.Out<at.infer<T, $>>;
```

`EnvSchema<T>` is defined as `at.validate<def, $>`. This is an ArkType-specific type that ensures the definition is valid for ArkType.

When `config.validator` is `"standard"`, we want a completely different signature.

## Proposed Overloads

We should split the overloads based on the `validator` property in `ArkEnvConfig`.

### 1. Standard Mode Overload
When `validator` is explicitly `"standard"`.

```ts
export function createEnv<const T extends Record<string, StandardSchemaV1>>(
def: T,
config: ArkEnvConfig & { validator: "standard" },
): { [K in keyof T]: StandardSchemaV1.InferOutput<T[K]> };
```

### 2. ArkType Mode Overload
When `validator` is `"arktype"` or omitted.

```ts
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T>,
config?: ArkEnvConfig & { validator?: "arktype" },
): distill.Out<at.infer<T, $>>;
```

## Complexity: Optional Config
The main challenge is that `config` is optional. If it's omitted, it defaults to ArkType mode.

If `config` is omitted, `validator: "standard"` is definitely not there, so it should fall back to ArkType.

## Implementation Details

### Handling `at.validate` and `distill.Out`
If ArkType is NOT installed, these types will error. However, since `arktype` is a peer dependency of `arkenv`, it's usually present in the environment where `arkenv` is used for development, even if the user wants to avoid it at runtime.

Wait, if the user explicitly wants to use ArkEnv *without* ArkType, they might not even have it in `devDependencies`. In that case, `arkenv`'s own source code will fail to compile if it imports from `arktype`.

However, `arkenv` is a library. Its types are shipped in `dist/`.

When a user uses `arkenv`:
- If they have `validator: "standard"`, they shouldn't need `arktype` types.
- If they DON'T have `arktype` installed, but `arkenv` exports types that depend on `arktype`, they might get errors from their build tool (like `tsc`) saying `arktype` not found.

To solve this properly, we might need to make the ArkType-specific return types conditional or use a "soft" version of them that doesn't hard-fail if `arktype` is missing (but typically, if you're using ArkType mode, you MUST have it).

Actually, the user said: "it obviously happens because the return types of createEnv are all arktype based, but in recent changes we've made it so that arktype is not required anymore."

If `arktype` is not required, then `arkenv` types must be usable without it.

Let's look at how `EnvSchema` is defined in `packages/arkenv/src/create-env.ts`:
```ts
export type EnvSchema<def> = at.validate<def, $>;
```

If we want this to be "soft", we can do something like:
```ts
export type EnvSchema<def> = at extends never ? def : at.validate<def, $>;
```
But `at` is an import.

Instead, we can use a helper type that checks if `at.validate` is available.

Actually, the easiest way to fix the inference specifically for Standard mode is to ensure the Standard mode overload comes first and is specific enough.

### Overload Order
The TypeScript compiler picks the first matching overload.

```ts
// 1. Standard Mode (Strict config)
export function createEnv<const T extends Record<string, StandardSchemaV1>>(
def: T,
config: ArkEnvConfig & { validator: "standard" },
): { [K in keyof T]: StandardSchemaV1.InferOutput<T[K]> };

// 2. ArkType Mode (Default or explicit)
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T>,
config?: ArkEnvConfig & { validator?: "arktype" },
): distill.Out<at.infer<T, $>>;
```

Wait, `EnvSchema<T>` might match a Standard Schema object too (since it's `Record<string, unknown>`-ish).

We need to make sure `EnvSchema<T>` doesn't swallow Standard Schema objects if `validator: "standard"` is passed.

## Standard Schema Inference Refinement
Standard Schema 1.0 requires `~standard` property.
We can use this to differentiate.

```ts
type StandardSchemaShape = Record<string, StandardSchemaV1>;

type InferredStandard<T extends StandardSchemaShape> = {
[K in keyof T]: StandardSchemaV1.InferOutput<T[K]>
};
```
32 changes: 32 additions & 0 deletions openspec/changes/fix-standard-mode-inference/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Change: Fix Standard Mode Type Inference

## Context
In `arkenv` v0.9.0, we introduced a `validator` mode to allow using ArkEnv without ArkType by leveraging the Standard Schema 1.0 specification. While this works at runtime, it is currently broken at compile-time: `createEnv` always returns an object inferred using ArkType's type system, even when `validator: "standard"` is specified.

This leads to two problems:
1. If ArkType is not installed, the type-level imports and utility types (like `distill.Out` and `at.infer`) might fail or behave unpredictably.
2. The inferred type is not correct because it relies on ArkType's understanding of the schema, which might differ from a Standard Schema validator's understanding (e.g., Zod, Valibot).

## Why
Standard Schema is a universal standard for validation libraries. Many users want to use ArkEnv's simple API with their existing Zod or Valibot schemas without being forced to install or use ArkType for type inference.

## High-Level Idea
Refactor `createEnv` overloads to use conditional types or separate overloads that switch the return type based on the `validator` configuration.

- If `validator: "standard"`, use `StandardSchemaV1.InferOutput` for each key in the schema.
- If `validator: "arktype"` (or default), continue using ArkType-based inference.

## What Changes

### 1. `createEnv` Overloads
We will update `createEnv` to correctly dispatch to the appropriate inference engine at the type level.

### 2. Centralized Standard Schema Types
We already have `packages/internal/types/src/standard-schema.ts`. We will use the `InferOutput` utility from this file.

## Explicit Non-Goals
- Changing the runtime validation logic (already implemented).
- Introducing complex auto-detection of schemas (we stick to the explicit `validator` flag).

## Design Principle
**Type Safety without Vendor Lock-in.** ArkEnv should provide first-class type safety for both ArkType and Standard Schema users, respecting the inference rules of each.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Standard Mode Inference Specification

## ADDED Requirements

### Requirement: Standard Mode Type Inference
When `createEnv` is used with `validator: "standard"`, the returned object MUST have types inferred from the Standard Schema validators.

#### Scenario: Zod inference in Standard Mode
- **GIVEN** a schema object containing Zod validators (which implement Standard Schema)
- **WHEN** `createEnv` is called with `validator: "standard"`
- **THEN** the returned object MUST have types exactly matching the Zod output types
- **AND** it MUST NOT be wrapped in ArkType-specific inference types like `distill.Out`

#### Scenario: Inferred types are usable without ArkType
- **GIVEN** `validator: "standard"` is used
- **THEN** types of the returned environment object MUST NOT depend on ArkType types at the consumer level
12 changes: 12 additions & 0 deletions openspec/changes/fix-standard-mode-inference/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## 1. Spec & Docs
- [x] 1.1 Add requirement for correct type inference in `openspec/changes/fix-standard-mode-inference/specs/inference/spec.md`.

## 2. Type Implementation
- [x] 2.1 Update `ArkEnvConfig` to be more precise about `validator` types (literals).
- [x] 2.2 Refactor `createEnv` overloads in `packages/arkenv/src/create-env.ts` to support Standard Schema inference.
- [x] 2.3 Fix the return cast in the implementation of `createEnv` for `standard` mode.

## 3. Validation
- [x] 3.1 Verify that `examples/without-arktype/src/index.ts` now has correct type inference (can be checked by hovering or running typecheck).
- [x] 3.2 Add a new test case in `packages/arkenv` specifically for type-level inference of standard mode.
- [x] 3.3 Run `openspec validate fix-standard-mode-inference --strict`.
3 changes: 2 additions & 1 deletion openspec/specs/validator-mode/spec.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# validator-mode Specification

## Purpose
TBD - created by archiving change add-explicit-validator-mode. Update Purpose after archive.
The validator-mode allows ArkEnv to operate in two distinct modes: `arktype` (default) and `standard`. Providing an explicit validator mode allows ArkEnv to be used without ArkType at runtime, reducing bundle size and removing the requirement for ArkType when it is not needed, while still providing full type safety via the Standard Schema specification.

## Requirements
### Requirement: Explicit Validator Mode
ArkEnv MUST support an explicit `validator` configuration option to choose between ArkType and Standard Schema validators.
Expand Down
16 changes: 7 additions & 9 deletions packages/arkenv/src/create-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
Dict,
InferType,
SchemaShape,
StandardSchemaV1,
} from "@repo/types";
import type { type as at, distill } from "arktype";
import { ArkEnvError } from "./errors.ts";
Expand Down Expand Up @@ -88,6 +89,10 @@ export type ArkEnvConfig = {
* @returns The parsed environment variables
* @throws An {@link ArkEnvError | error} if the environment variables are invalid.
*/
export function createEnv<const T extends Record<string, StandardSchemaV1>>(
def: T,
config: ArkEnvConfig & { validator: "standard" },
): { [K in keyof T]: StandardSchemaV1.InferOutput<T[K]> };
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T>,
config?: ArkEnvConfig,
Expand All @@ -96,10 +101,6 @@ export function createEnv<T extends CompiledEnvSchema>(
def: T,
config?: ArkEnvConfig,
): InferType<T>;
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T> | CompiledEnvSchema,
config?: ArkEnvConfig,
): distill.Out<at.infer<T, $>> | InferType<typeof def>;
export function createEnv<const T extends SchemaShape>(
def: EnvSchema<T> | CompiledEnvSchema,
config: ArkEnvConfig = {},
Expand Down Expand Up @@ -149,14 +150,11 @@ export function createEnv<const T extends SchemaShape>(
}
}

return parseStandard(
def as Record<string, unknown>,
config,
) as unknown as distill.Out<at.infer<T, $>>;
return parseStandard(def as Record<string, unknown>, config);
}

const validator = loadArkTypeValidator();
const { parse } = validator;

return parse(def as any, config) as any;
return parse(def, config);
}
120 changes: 120 additions & 0 deletions packages/arkenv/src/standard-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { createEnv } from "./create-env.ts";

// Mock Standard Schema validators for testing
const createMockStandardSchema = <TOutput>(outputValue: TOutput) => ({
"~standard": {
version: 1 as const,
vendor: "mock",
types: {} as { input: unknown; output: TOutput },
validate: (value: unknown) => ({ value: outputValue }),
},
});

describe("Standard Mode Type Inference", () => {
it("should infer correct types from Standard Schema validators", () => {
vi.stubEnv("STRING_VAR", "test");
vi.stubEnv("NUMBER_VAR", "42");
vi.stubEnv("BOOLEAN_VAR", "true");

const env = createEnv(
{
STRING_VAR: createMockStandardSchema("test-string"),
NUMBER_VAR: createMockStandardSchema(123),
BOOLEAN_VAR: createMockStandardSchema(true),
},
{ validator: "standard" },
);

// Type-level assertions
expectTypeOf(env.STRING_VAR).toBeString();
expectTypeOf(env.NUMBER_VAR).toBeNumber();
expectTypeOf(env.BOOLEAN_VAR).toBeBoolean();

// Runtime assertions
expect(env.STRING_VAR).toBe("test-string");
expect(env.NUMBER_VAR).toBe(123);
expect(env.BOOLEAN_VAR).toBe(true);

vi.unstubAllEnvs();
});

it("should not have ArkType-specific types in standard mode", () => {
vi.stubEnv("TEST_VAR", "value");

const env = createEnv(
{
TEST_VAR: createMockStandardSchema("output"),
},
{ validator: "standard" },
);

// Verify the type is a plain string, not wrapped in ArkType types
expectTypeOf(env.TEST_VAR).toBeString();

vi.unstubAllEnvs();
});

it("should correctly infer object types from Standard Schema", () => {
vi.stubEnv("OBJECT_VAR", "{}");

type ExpectedOutput = { foo: string; bar: number };
const env = createEnv(
{
OBJECT_VAR: createMockStandardSchema<ExpectedOutput>({
foo: "test",
bar: 42,
}),
},
{ validator: "standard" },
);

expectTypeOf(env.OBJECT_VAR).toEqualTypeOf<ExpectedOutput>();
expect(env.OBJECT_VAR).toEqual({ foo: "test", bar: 42 });

vi.unstubAllEnvs();
});

it("should throw error when ArkType DSL strings are used in standard mode", () => {
expect(() =>
createEnv(
{
TEST_VAR: "string",
} as any,
{ validator: "standard" },
),
).toThrow(/ArkType DSL strings are not supported in "standard" mode/);
});

it("should throw error when non-standard validators are used", () => {
expect(() =>
createEnv(
{
TEST_VAR: { notAStandardSchema: true },
} as any,
{ validator: "standard" },
),
).toThrow(/Invalid validator: expected a Standard Schema 1.0 validator/);
});

it("should maintain type safety with multiple validators", () => {
vi.stubEnv("VAR1", "a");
vi.stubEnv("VAR2", "b");
vi.stubEnv("VAR3", "c");

const env = createEnv(
{
VAR1: createMockStandardSchema("string-output"),
VAR2: createMockStandardSchema(999),
VAR3: createMockStandardSchema({ nested: "object" }),
},
{ validator: "standard" },
);

expectTypeOf(env.VAR1).toBeString();
expectTypeOf(env.VAR2).toBeNumber();
expectTypeOf(env.VAR3).toEqualTypeOf<{ nested: string }>();

vi.unstubAllEnvs();
});
});
6 changes: 4 additions & 2 deletions packages/bun-plugin/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type { Loader, PluginBuilder } from "bun";
export function processEnvSchema<T extends SchemaShape>(
options: EnvSchema<T> | CompiledEnvSchema,
): Map<string, string> {
const env = createEnv<T>(options, { env: process.env });
// Use type assertion because options could be either EnvSchema<T> or CompiledEnvSchema
// The union type can't match the overloads directly
const env: SchemaShape = createEnv(options as any, { env: process.env });
const prefix = "BUN_PUBLIC_";
const filteredEnv = Object.fromEntries(
Object.entries(<SchemaShape>env).filter(([key]) => key.startsWith(prefix)),
Object.entries(env).filter(([key]) => key.startsWith(prefix)),
);
const envMap = new Map<string, string>();
for (const [key, value] of Object.entries(filteredEnv)) {
Expand Down
9 changes: 4 additions & 5 deletions packages/vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,16 @@ export default function arkenv<const T extends SchemaShape>(

// Load environment based on the custom config
const envDir = config.envDir ?? config.root ?? process.cwd();
// TODO: We're using type assertions and explicitly pass in the type arguments here to avoid
// "Type instantiation is excessively deep and possibly infinite" errors.
// Ideally, we should find a way to avoid these assertions while maintaining type safety.
const env = createEnv<T>(options, {
// Use type assertion because options could be either EnvSchema<T> or CompiledEnvSchema
// The union type can't match the overloads directly
const env: SchemaShape = createEnv(options as any, {
env: loadEnv(mode, envDir, ""),
});

// Filter to only include environment variables matching the prefix
// This prevents server-only variables from being exposed to client code
const filteredEnv = Object.fromEntries(
Object.entries(<SchemaShape>env).filter(([key]) =>
Object.entries(env).filter(([key]) =>
prefixes.some((prefix) => key.startsWith(prefix)),
),
);
Expand Down
Loading