diff --git a/.changeset/better-crews-pump.md b/.changeset/better-crews-pump.md new file mode 100644 index 000000000..c83c42276 --- /dev/null +++ b/.changeset/better-crews-pump.md @@ -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`. diff --git a/openspec/changes/fix-standard-mode-inference/design.md b/openspec/changes/fix-standard-mode-inference/design.md new file mode 100644 index 000000000..51593897f --- /dev/null +++ b/openspec/changes/fix-standard-mode-inference/design.md @@ -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( + def: EnvSchema, + config?: ArkEnvConfig, +): distill.Out>; +``` + +`EnvSchema` is defined as `at.validate`. 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>( + def: T, + config: ArkEnvConfig & { validator: "standard" }, +): { [K in keyof T]: StandardSchemaV1.InferOutput }; +``` + +### 2. ArkType Mode Overload +When `validator` is `"arktype"` or omitted. + +```ts +export function createEnv( + def: EnvSchema, + config?: ArkEnvConfig & { validator?: "arktype" }, +): distill.Out>; +``` + +## 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 = at.validate; +``` + +If we want this to be "soft", we can do something like: +```ts +export type EnvSchema = at extends never ? def : at.validate; +``` +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>( + def: T, + config: ArkEnvConfig & { validator: "standard" }, +): { [K in keyof T]: StandardSchemaV1.InferOutput }; + +// 2. ArkType Mode (Default or explicit) +export function createEnv( + def: EnvSchema, + config?: ArkEnvConfig & { validator?: "arktype" }, +): distill.Out>; +``` + +Wait, `EnvSchema` might match a Standard Schema object too (since it's `Record`-ish). + +We need to make sure `EnvSchema` 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; + +type InferredStandard = { + [K in keyof T]: StandardSchemaV1.InferOutput +}; +``` diff --git a/openspec/changes/fix-standard-mode-inference/proposal.md b/openspec/changes/fix-standard-mode-inference/proposal.md new file mode 100644 index 000000000..135cd4590 --- /dev/null +++ b/openspec/changes/fix-standard-mode-inference/proposal.md @@ -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. diff --git a/openspec/changes/fix-standard-mode-inference/specs/inference/spec.md b/openspec/changes/fix-standard-mode-inference/specs/inference/spec.md new file mode 100644 index 000000000..2439331a8 --- /dev/null +++ b/openspec/changes/fix-standard-mode-inference/specs/inference/spec.md @@ -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 diff --git a/openspec/changes/fix-standard-mode-inference/tasks.md b/openspec/changes/fix-standard-mode-inference/tasks.md new file mode 100644 index 000000000..ae2c56fa3 --- /dev/null +++ b/openspec/changes/fix-standard-mode-inference/tasks.md @@ -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`. diff --git a/openspec/specs/validator-mode/spec.md b/openspec/specs/validator-mode/spec.md index fc4feefe2..7fc18b650 100644 --- a/openspec/specs/validator-mode/spec.md +++ b/openspec/specs/validator-mode/spec.md @@ -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. diff --git a/packages/arkenv/src/create-env.ts b/packages/arkenv/src/create-env.ts index 6fd7a4340..9054a3735 100644 --- a/packages/arkenv/src/create-env.ts +++ b/packages/arkenv/src/create-env.ts @@ -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"; @@ -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>( + def: T, + config: ArkEnvConfig & { validator: "standard" }, +): { [K in keyof T]: StandardSchemaV1.InferOutput }; export function createEnv( def: EnvSchema, config?: ArkEnvConfig, @@ -96,10 +101,6 @@ export function createEnv( def: T, config?: ArkEnvConfig, ): InferType; -export function createEnv( - def: EnvSchema | CompiledEnvSchema, - config?: ArkEnvConfig, -): distill.Out> | InferType; export function createEnv( def: EnvSchema | CompiledEnvSchema, config: ArkEnvConfig = {}, @@ -149,14 +150,11 @@ export function createEnv( } } - return parseStandard( - def as Record, - config, - ) as unknown as distill.Out>; + return parseStandard(def as Record, config); } const validator = loadArkTypeValidator(); const { parse } = validator; - return parse(def as any, config) as any; + return parse(def, config); } diff --git a/packages/arkenv/src/standard-mode.test.ts b/packages/arkenv/src/standard-mode.test.ts new file mode 100644 index 000000000..80cba6182 --- /dev/null +++ b/packages/arkenv/src/standard-mode.test.ts @@ -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 = (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({ + foo: "test", + bar: 42, + }), + }, + { validator: "standard" }, + ); + + expectTypeOf(env.OBJECT_VAR).toEqualTypeOf(); + 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(); + }); +}); diff --git a/packages/bun-plugin/src/utils.ts b/packages/bun-plugin/src/utils.ts index a257dbc96..51528049b 100644 --- a/packages/bun-plugin/src/utils.ts +++ b/packages/bun-plugin/src/utils.ts @@ -9,10 +9,12 @@ import type { Loader, PluginBuilder } from "bun"; export function processEnvSchema( options: EnvSchema | CompiledEnvSchema, ): Map { - const env = createEnv(options, { env: process.env }); + // Use type assertion because options could be either EnvSchema 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(env).filter(([key]) => key.startsWith(prefix)), + Object.entries(env).filter(([key]) => key.startsWith(prefix)), ); const envMap = new Map(); for (const [key, value] of Object.entries(filteredEnv)) { diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 0e3ff3c49..397db4f42 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -60,17 +60,16 @@ export default function arkenv( // 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(options, { + // Use type assertion because options could be either EnvSchema 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(env).filter(([key]) => + Object.entries(env).filter(([key]) => prefixes.some((prefix) => key.startsWith(prefix)), ), );