Typing import.meta.env with a Vite env.d.ts

import.meta.env.VITE_API_URL is typed as string | undefined — or any — until you declare your variables in an ImportMetaEnv interface, so a typo compiles and a missing value surfaces only at runtime. This guide adds a typed vite-env.d.ts that gives autocomplete, catches typos, and models required-vs-optional. It applies the environment variables and build modes in Vite rules to the type layer; the parent overview is Vite Configuration Ecosystem.

The problem exists because import.meta.env is not an ordinary object with a fixed shape — it is a synthetic namespace that Vite constructs at build time by scanning your .env files, filtering to the VITE_ prefix, and inlining each hit as a string literal via define-style replacement. TypeScript has no way to know which keys that process produced, so the type shipped in vite/client deliberately makes the index signature permissive: any string key resolves to a value type rather than to an error. That permissiveness is convenient for the framework author and dangerous for you, because it means every access is assumed valid. import.meta.env.VITE_API_ULR — a transposed RL — is not a mistake the compiler can see; it is just another string key that resolves to undefined, and undefined is a member of the value type, so nothing complains.

What breaks without the fix is not the build but the runtime, and it breaks late. A misspelled key returns undefined, which flows into a fetch(undefined + '/users') that hits http://localhost:3000/undefined/users, or into a feature flag that silently reads false, or into an analytics initializer that no-ops. None of these throw at the call site; they surface as a blank panel, a 404, or a metric that quietly stops reporting — the kind of defect that reaches staging because the person who introduced it never saw a red squiggle. Declaring ImportMetaEnv moves the failure from a runtime symptom back to the exact line and character where the typo lives, and turns the editor’s autocomplete into the source of truth for which variables exist. It costs one file and pays for itself the first time someone renames a variable and the compiler enumerates every stale reference.

Declaring ImportMetaEnv turns runtime typos into type errors Without a declaration import.meta.env keys are loosely typed so a misspelled variable compiles and fails at runtime; declaring an ImportMetaEnv interface makes the compiler flag the typo and autocomplete the valid keys. Type the env, catch typos at compile time no declarationenv.VITE_API_ULR compilesundefined at runtime ImportMetaEnv declaredtypo is a type errorautocomplete valid keys
Figure: the declaration is what turns a silent runtime undefined into a compile-time error.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+, TypeScript 5.5.x
npm create vite@latest env-types-demo -- --template react-ts
cd env-types-demo && npm install
printf "VITE_API_URL=https://api.example.com\nVITE_FEATURE_BETA=true\n" > .env

Vite only exposes variables prefixed VITE_ to client code, and the starter template already has a vite-env.d.ts with /// <reference types="vite/client" />. That reference gives the base ImportMeta.env type; you augment it.

The augmentation works because ImportMetaEnv is an open interface, not a sealed type. TypeScript merges all interface declarations that share a name across the compilation, so when you write your own interface ImportMetaEnv { ... } in a file that is part of the program, the compiler unions your members with the ones vite/client ships. The base declaration includes structural members like MODE, BASE_URL, PROD, DEV, and SSR, plus a permissive index signature. Your declaration adds concrete named keys on top. Because a named property is more specific than an index signature, your VITE_API_URL: string wins for that key while the index signature still catches everything you have not named — which is exactly why a typo on an undeclared key does not error unless you also tighten the index signature, a trade-off covered below.

Two setup facts decide whether any of this takes effect. First, the file must be reachable by the TypeScript program: it has to fall under an include glob (or be pulled in by a /// <reference> chain that itself is included), or the whole declaration is compiled in isolation and never merged. Second, the vite/client reference must stay at the top of the file; drop it and you lose the base MODE/PROD/DEV members and the built-in typings for asset imports like *.svg and ?raw, which live in the same declaration bundle.

Only VITE_-prefixed vars reach the client Vite exposes only variables prefixed with VITE_ to client code, so unprefixed variables are server-only and should never appear in the ImportMetaEnv interface that types client access. VITE_API_URLexposed to client DATABASE_URLserver-only, never typed here only the prefixed vars belong in the interface
Figure: only VITE_-prefixed vars are client-visible, so only they belong in the interface.

Diagnosis workflow

Before writing the declaration, confirm what the compiler currently believes, because the fix is worthless if the file you edit is not in the program or if you type the values wrong. Work through these three checks in order; each one narrows down where the real risk sits.

  1. Confirm the loose typing. Hover import.meta.env.VITE_API_URL in the editor — without a declaration it is string | boolean | undefined or any, and a typo is not flagged. The symptom is that autocomplete offers nothing after env. beyond the built-in MODE/PROD/DEV members, and that inventing a nonsense key like import.meta.env.NONSENSE produces no error. The root cause is the permissive index signature in the shipped type. Confirm it by deliberately writing a misspelled key and checking that npx tsc --noEmit still passes; if it does, you have proven there is no declaration in effect yet.

  2. List the variables and their real types. VITE_API_URL is a string; VITE_FEATURE_BETA is really a boolean but arrives as the string "true". Enumerate every VITE_-prefixed key across every .env, .env.local, .env.development, and .env.production file, because a key that appears only in production is still a key your code reads unconditionally. For each one, write down the type you want (boolean, number, enum of allowed values, URL) versus the type you get, which is always string. That gap — wanted versus delivered — is the list of parse steps you owe the env.ts module later. Skipping this inventory is how a numeric VITE_PORT ends up compared against a number and silently mismatching because "3000" !== 3000.

  3. Decide required vs optional. A variable the app cannot run without should be typed non-optional and validated at startup, not left string | undefined. The test is blunt: if the first render or first request cannot function without the value, it is required and belongs to a fail-fast validator that throws with the variable’s name. If the feature degrades gracefully when the value is absent — an optional analytics ID, a debug toggle — mark it optional with ? and handle the undefined branch explicitly. Getting this wrong in the safe direction (typing a required var as optional) pushes a undefined deep into the app; getting it wrong in the unsafe direction (typing an optional var as required) makes a build refuse to run in an environment that never needed the value.

Env values arrive as strings regardless of intent Every env variable arrives as a string, so a boolean flag like VITE_FEATURE_BETA is the literal string true and must be parsed; typing it as boolean without parsing is a lie the compiler cannot catch. string valuesVITE_FEATURE_BETA = "true"parse to boolean typed boolean, not parsed"false" is truthysilent bug
Figure: typing a flag as boolean without parsing lets the string "false" read as true.

Annotated solution config

The solution is two files with two different jobs, and conflating them is the most common way people get half a fix. The vite-env.d.ts is a pure declaration: it exists only to inform the compiler and the editor, it emits no code, and it must describe the values as they actually arrive — which means every entry is string or string | undefined, never boolean or number. The env.ts module is the opposite: it is real runtime code that reads those strings once, coerces them into the types the app wants, validates the required ones, and exports a frozen object the rest of the codebase imports instead of touching import.meta.env directly. The declaration buys you editor safety; the module buys you runtime correctness. You need both.

// src/vite-env.d.ts — augment the env types with your variables.
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;            // required string
  readonly VITE_FEATURE_BETA: string;       // "true" | "false" — parse at use
  readonly VITE_ANALYTICS_ID?: string;      // optional
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
// src/env.ts — parse and validate at startup so the rest of the app is safe.
function required(key: keyof ImportMetaEnv): string {
  const v = import.meta.env[key];
  if (v === undefined) throw new Error(`Missing env var: ${key}`);
  return v;
}

export const env = {
  apiUrl: required('VITE_API_URL'),
  featureBeta: import.meta.env.VITE_FEATURE_BETA === 'true',   // real boolean
  analyticsId: import.meta.env.VITE_ANALYTICS_ID,              // string | undefined
};

Note the deliberate asymmetry in env.ts. required() is keyed by keyof ImportMetaEnv, so you cannot pass a string that is not a declared variable — the two files reinforce each other, and renaming a key in the .d.ts immediately breaks the required('VITE_API_URL') call that still names the old key. The boolean is computed with an explicit === 'true' rather than a truthiness check, because Boolean("false") is true and any non-empty string is truthy; comparing against the literal 'true' is the only coercion that treats "false", empty, and absent as false. The optional analyticsId is left as string | undefined on purpose so its consumers are forced to handle the missing case.

When the “real type” you want is a number or a fixed set of allowed values, add typed parse helpers so the coercion is total and the failure mode is a thrown error rather than a NaN that poisons arithmetic downstream:

// src/env.ts (continued) — parse numbers and constrained enums, TypeScript 5.5.x.
function requiredInt(key: keyof ImportMetaEnv): number {
  const n = Number(required(key));
  if (!Number.isInteger(n)) throw new Error(`Env ${key} must be an integer`);
  return n;
}

function oneOf<const T extends readonly string[]>(
  key: keyof ImportMetaEnv,
  allowed: T,
): T[number] {
  const v = required(key);
  if (!allowed.includes(v as T[number])) {
    throw new Error(`Env ${key} must be one of ${allowed.join(', ')}, got "${v}"`);
  }
  return v as T[number];
}

// Usage — the return types are `number` and `"stripe" | "paypal"`, not string.
// Declare VITE_PORT and VITE_PAYMENT_GATEWAY (as string) in vite-env.d.ts first,
// otherwise `keyof ImportMetaEnv` rejects the keys and tsc errors here.
export const port = requiredInt('VITE_PORT');
export const gateway = oneOf('VITE_PAYMENT_GATEWAY', ['stripe', 'paypal'] as const);
Declare the types, then parse into a typed env object The vite-env.d.ts declares each variable as a string or optional string for autocomplete and typo-checking, and a small env module then parses and validates those strings into a typed object with real booleans and required values. vite-env.d.tsdeclare keys as strings env.tsparse + validate types for the editor, parsing for correctness
Figure: the declaration serves the editor; the env.ts module serves correctness by parsing and validating.

Verification

# TypeScript 5.5.x — a typo now fails the type check.
npx tsc --noEmit
# Try import.meta.env.VITE_API_ULR (typo): tsc reports the property does not exist.

With the interface declared, a misspelled variable is a compile error and the editor autocompletes valid keys. env.featureBeta is a real boolean, and env.apiUrl throws at startup if the variable is missing instead of surfacing as undefined deep in the app.

Two verifications matter and they catch different classes of defect. The tsc --noEmit pass is a static check: it proves that every import.meta.env.VITE_* access in the codebase names a declared key, so it catches typos and stale references left behind after a rename. It says nothing about whether the value is actually present at runtime, because the declaration describes shape, not existence — a key typed string still compiles even when the corresponding .env line was never written. The startup validation in env.ts is the runtime check that closes that gap: it runs the moment the module is first imported, reads the real inlined value, and throws with the variable’s name if a required one is missing. Wire the env.ts import into your entry point (main.tsx) so the throw happens during bootstrap rather than lazily when some route first touches the value.

To confirm both are working, run tsc --noEmit after deliberately breaking a key name and watch it fail, then restore the name and delete the VITE_API_URL line from .env, run npm run dev, and confirm the app throws Missing env var: VITE_API_URL at startup rather than rendering a broken screen. If the type check passes on a misspelled key, your .d.ts is not in the program (see the tsconfig gotcha below). If the startup throw never fires on a deleted variable, your env.ts is not imported early enough, or the code path still reads import.meta.env directly instead of the validated env object.

tsc flags the typo, startup catches the missing value After declaring the interface, tsc noEmit reports a misspelled env key as a type error, and the validated env module throws at startup for a missing required variable instead of letting undefined propagate. typo → tsc errorcaught at compile missing → startup thrownot deep undefined two classes of bug caught earlier
Figure: typos die at compile time and missing values die at startup — neither reaches a component.

Gotchas & edge cases

Four env-typing edge cases Declaring a var as boolean lies because values are strings; the vite-env.d.ts must be included by tsconfig; only VITE_-prefixed vars should be declared; and modes mean a var present in one env file may be absent in another so keep required ones optional-safe. declared as boolean→ it is a string, parse it d.ts not in tsconfig→ include it typed a non-VITE var→ it is undefined in client mode-specific vars→ validate per mode
Figure: typing a var as boolean is the seductive mistake — the value is always a string.
  • Do not type as boolean. The symptom is a feature that stays on even when the flag reads VITE_FEATURE_BETA=false. The root cause is that env values are always strings, so a member typed boolean is a lie the compiler cannot detect and every consumer does if (env.VITE_FEATURE_BETA), which is truthy for the non-empty string "false". The fix is to declare the member as string in the .d.ts and coerce with === 'true' in env.ts. Confirm by setting the value to false and checking the feature is off; if it is still on, some consumer is reading the raw string instead of the parsed boolean.

  • Include the .d.ts. The symptom is that autocomplete works in the editor but tsc --noEmit in CI passes on a misspelled key, or vice versa. The root cause is that the editor’s language server and the CLI tsc can resolve different sets of files: the editor is forgiving about loose files, while tsc only compiles what the active tsconfig.json selects. Ensure the include glob covers the directory holding vite-env.d.ts (the Vite template uses "include": ["src"]) and that no exclude entry or a stray project-references split removes it. Confirm by running tsc --noEmit --explainFiles | grep vite-env and checking the file is listed as part of the program.

  • Only VITE_ vars. The symptom is a declared variable that is typed as string but is always undefined at runtime. The root cause is that Vite’s client bundle only inlines keys matching the prefix (default VITE_, configurable via envPrefix), so declaring DATABASE_URL in ImportMetaEnv produces a type that promises a value the build never provides. Keep unprefixed secrets server-side and never list them in the client interface; if you genuinely need a different prefix, change envPrefix in vite.config.ts and keep the interface names in sync. Confirm by logging the value in the browser — a real inlined variable shows the literal string, a non-prefixed one is undefined.

  • Mode differences. The symptom is a build that works in development and fails in production, or the reverse, because a variable defined in .env.production has no counterpart in .env.development. The root cause is that Vite loads env files by mode, so import.meta.env contents differ per --mode, yet the single ImportMetaEnv interface describes all modes at once as if every key were always present. The fix is to keep the type honest — mark truly mode-specific variables optional, or accept that they are required and let the startup validator throw in any mode that omits them. Confirm by running vite build --mode development and vite build --mode production and checking both either succeed or fail loudly with the missing variable’s name, never silently.

How it works under the hood

Understanding why the declaration behaves the way it does keeps you from cargo-culting it. At build time Vite reads the .env files for the active mode, filters to keys matching envPrefix, and stores them. It does not hand your code an object at runtime; instead it performs a static replacement. Every literal occurrence of import.meta.env.VITE_API_URL in your source is substituted with the string "https://api.example.com" during transform, exactly the way a define replacement works. That is why dynamic access such as import.meta.env['VITE_' + name] can behave differently from static access — the replacement is textual and keyed on the literal member expression, and the fallback object that survives to runtime carries only the built-in members plus whatever the bundler could not statically inline.

The typing is entirely orthogonal to that replacement. TypeScript’s ImportMetaEnv interface never participates in the build; it is erased before Vite ever transforms the code. This separation is the whole reason you must not type a value as boolean: the compiler will happily believe the interface, but the string that gets inlined is still "true", and no coercion happens for free. Declaration merging is what lets your interface coexist with the framework’s — the compiler unions members from every same-named interface in the program, named properties override the base index signature for their specific keys, and the result is a single structural type the language server queries for autocomplete. Nothing here is magic; it is ordinary interface merging pointed at a namespace the bundler happens to synthesize.

Tightening the index signature

The default vite/client type keeps a permissive index signature, so an undeclared key like import.meta.env.VITE_TYPO still resolves rather than erroring. If you want the compiler to reject any key you have not declared — turning the interface into an exhaustive allow-list — you can omit the index signature entirely, since your named-only interface does not add one and the merged base one is what remains. In practice the cleaner move is to route all access through the validated env object and lint against direct import.meta.env use, because that also catches the coercion mistakes the index signature cannot. Decide deliberately: an exhaustive interface is stricter but noisier when you add a variable and forget to declare it before using it; the permissive default is friendlier at the cost of letting typos through on undeclared keys.

CI integration

Typing is only a guardrail if CI enforces it, because a red squiggle a developer ignores locally is worth nothing. Add tsc --noEmit as a required check so a stale env reference cannot merge, and run it against the same tsconfig.json the editor uses so the two agree on which files — including vite-env.d.ts — are in the program.

# .github/workflows/ci.yml — fail the build on a stale or misspelled env key.
# actions/checkout@v4, actions/setup-node@v4, Node 20.x
name: ci
on: [push, pull_request]
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npx tsc --noEmit          # catches typos and stale env keys
      - run: npx vite build            # inlines real values, surfaces missing prefix issues

The two steps are complementary. tsc --noEmit proves the type layer is consistent, and vite build proves the values actually inline for the mode being built. Run the build against the mode you deploy, feeding real VITE_* values through the CI environment or a committed .env.production without secrets, so a variable that exists only on a developer’s machine cannot pass CI and then fail in the deployed bundle.

Comparison with a schema validator

The hand-rolled required()/oneOf() approach is deliberately dependency-free, and for a handful of variables it is the right amount of machinery. Once the surface grows — many variables, cross-field rules, coercion to dates or URLs, precise error messages — a schema library such as zod or envalid earns its place by making the schema the single source of truth and deriving the type from it, so the declaration and the validator can never drift apart.

// src/env.ts — schema-first with zod 3.23.x; the type is inferred, not hand-written.
import { z } from 'zod';

const schema = z.object({
  VITE_API_URL: z.string().url(),
  VITE_FEATURE_BETA: z.enum(['true', 'false']).transform((v) => v === 'true'),
  VITE_ANALYTICS_ID: z.string().optional(),
});

export const env = schema.parse(import.meta.env);   // throws with a precise path on failure

The trade-off is real: the schema validates and coerces at runtime with a good error message and derives a boolean for VITE_FEATURE_BETA automatically, but it adds a dependency to the client bundle and still relies on the .d.ts for editor autocomplete on the raw import.meta.env. Use the hand-rolled helpers when the variable count is small and you want zero runtime dependencies; reach for a schema once the validation logic would otherwise sprawl across a dozen bespoke required* functions.

When not to use this

This pattern is not free of judgement. If your app reads exactly one or two env variables and never grows, the .d.ts plus a single required() call is enough and the env.ts module, the enum helpers, and a schema library are overkill you will maintain for no benefit. If your configuration must change without a rebuild — feature flags flipped by ops, per-tenant endpoints resolved at request time — env inlining is the wrong tool entirely, because Vite bakes the values into the bundle at build time and they cannot change afterward; use a runtime config endpoint or injected window.__CONFIG__ instead, and do not pretend a typed import.meta.env gives you dynamic configuration. And if a value is secret, no amount of typing makes it safe on the client: anything with the VITE_ prefix is inlined into JavaScript any visitor can read, so the interface is documentation of what is public, never a security boundary.