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.
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.
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.
-
Confirm the loose typing. Hover
import.meta.env.VITE_API_URLin the editor — without a declaration it isstring | boolean | undefinedorany, and a typo is not flagged. The symptom is that autocomplete offers nothing afterenv.beyond the built-inMODE/PROD/DEVmembers, and that inventing a nonsense key likeimport.meta.env.NONSENSEproduces no error. The root cause is the permissive index signature in the shipped type. Confirm it by deliberately writing a misspelled key and checking thatnpx tsc --noEmitstill passes; if it does, you have proven there is no declaration in effect yet. -
List the variables and their real types.
VITE_API_URLis a string;VITE_FEATURE_BETAis really a boolean but arrives as the string"true". Enumerate everyVITE_-prefixed key across every.env,.env.local,.env.development, and.env.productionfile, 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 alwaysstring. That gap — wanted versus delivered — is the list of parse steps you owe theenv.tsmodule later. Skipping this inventory is how a numericVITE_PORTends up compared against a number and silently mismatching because"3000" !== 3000. -
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 theundefinedbranch explicitly. Getting this wrong in the safe direction (typing a required var as optional) pushes aundefineddeep 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.
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);
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.
Gotchas & edge cases
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 readsVITE_FEATURE_BETA=false. The root cause is that env values are always strings, so a member typedbooleanis a lie the compiler cannot detect and every consumer doesif (env.VITE_FEATURE_BETA), which is truthy for the non-empty string"false". The fix is to declare the member asstringin the.d.tsand coerce with=== 'true'inenv.ts. Confirm by setting the value tofalseand 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 buttsc --noEmitin CI passes on a misspelled key, or vice versa. The root cause is that the editor’s language server and the CLItsccan resolve different sets of files: the editor is forgiving about loose files, whiletsconly compiles what the activetsconfig.jsonselects. Ensure theincludeglob covers the directory holdingvite-env.d.ts(the Vite template uses"include": ["src"]) and that noexcludeentry or a stray project-references split removes it. Confirm by runningtsc --noEmit --explainFiles | grep vite-envand checking the file is listed as part of the program. -
Only
VITE_vars. The symptom is a declared variable that is typed asstringbut is alwaysundefinedat runtime. The root cause is that Vite’s client bundle only inlines keys matching the prefix (defaultVITE_, configurable viaenvPrefix), so declaringDATABASE_URLinImportMetaEnvproduces 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, changeenvPrefixinvite.config.tsand 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 isundefined. -
Mode differences. The symptom is a build that works in
developmentand fails inproduction, or the reverse, because a variable defined in.env.productionhas no counterpart in.env.development. The root cause is that Vite loads env files by mode, soimport.meta.envcontents differ per--mode, yet the singleImportMetaEnvinterface 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 runningvite build --mode developmentandvite build --mode productionand 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.
Related
- Environment variables and build modes in Vite — the parent cluster on env loading and modes.
- Fixing import.meta.env undefined in production builds — the runtime failure this typing prevents.
- Managing multiple env files across Vite environments — the mode-file layout these types describe.