TypeScriptの環境変数の型は、何もしなければ process.env が「string または undefined」、Vite の import.meta.env の独自変数が any です。 .d.ts ファイルでインターフェースを拡張すると補完と型チェックが効くようになりますが、型定義は実行時の値を変えません。設定し忘れた変数は、型が string でも実行時には undefined のままです。

この記事では、コピペで使える型定義ファイルを示したうえで、「型を拡張する」「起動時に自前で検証する」「zod で検証する」の3方式を、未設定・空文字・数値変換の3ケースで実際に動かして比べます。.env ファイルの書式や読み込み方には触れず、型と検証に絞ります。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / TypeScript 7.0.2(npx tsc --version の結果)/ @types/node 26.6.4 / Vite 8.3.2 / zod 4.6.5。Vite の例は vite build の出力を Node.js で実行して確かめています。

環境変数の型の早見表

知りたいこと 答え
process.env.X の既定の型 string | undefined(どんな名前でも読める)
process.env に型を足す場所 NodeJS.ProcessEnv インターフェースを .d.ts で拡張
import.meta.env.VITE_X の既定の型 any(Vite の型定義による)
import.meta.env に型を足す場所 ImportMetaEnv インターフェースを vite-env.d.ts で拡張
型を足すと実行時の値は変わるか 変わらない。未設定なら undefined のまま
環境変数の値の型 実行時は常に文字列。数値や真偽値は自分で変換する
未設定を起動時に見つける方法 自前の検証関数、または zod のスキーマで検証する
型の拡張が効かないとき .d.ts に import があると別扱いになる。declare global で囲むか import を外す

3方式の違いは次のとおりです(詳しい出力は後半に載せます)。

方式 補完・型チェック 未設定に気づく時点 空文字の扱い 数値への変換
型を拡張するだけ 効く 値を使った瞬間(実行時エラー) そのまま通る Number() を自分で書く。NaN も通る
起動時に自前で検証 効く(返り値の型) 起動時 自分で決められる 自分で書いた条件どおり
zod で検証 効く(スキーマから型を生成) 起動時(複数の問題を一度に表示) min(1) などで指定 z.coerce.number() で変換と検査

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript環境変数の型|process.envとundefined」

process.env の既定の型を確かめる

process.env の型は @types/node が定義しています。中身は「文字列キーで、値は string か undefined」という辞書型です。

// node_modules/@types/node/process.d.ts より
interface ProcessEnv extends Dict<string> {}

そのため、型定義を足していない状態で値をそのまま使うと、undefined の可能性を指摘されます。

{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["*.ts"]
}
// plain.ts
const url = process.env.DATABASE_URL;
console.log(url.length);
const port: number = process.env.PORT;
$ npx tsc -p .
plain.ts(2,13): error TS18048: 'url' is possibly 'undefined'.
plain.ts(3,7): error TS2322: Type 'string | undefined' is not assignable to type 'number'.
  Type 'undefined' is not assignable to type 'number'.

--locale ja を付けると、TS18048 は次の日本語になります。

error TS18048: 'port' は 'undefined' の可能性があります。

なお、tsconfig.json の types に "node" を入れていないと、TypeScript 7.0.2 では process そのものが見つからず、次のエラーになりました。

error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

process.env に型を足す(NodeJS.ProcessEnv の拡張)

コピペ用の env.d.ts

NodeJS.ProcessEnv は interface なので、同じ名前で宣言を書き足すと定義が合成されます(宣言のマージ)。次のファイルを tsconfig.json の include に入る場所へ置きます。

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    readonly NODE_ENV: "development" | "production" | "test";
    readonly DATABASE_URL: string;
    readonly PORT?: string;
  }
}

書き方のポイントは3つです。

  • 値の型は string か文字列リテラルの組み合わせにする。環境変数は実行時には必ず文字列です。PORT: number と書いた場合の結果は、後ろの「env.d.ts に number と書いた場合」で示します。
  • 設定しないことがある変数には ? を付ける。型が string | undefined になり、使う側で確認が必要になります。
  • readonly を付けると、コード中の書き換えを型で防げます。

import を含むファイルでは declare global で囲む

.d.ts の先頭に import や export があると、そのファイルはモジュールとして扱われ、declare namespace NodeJS がファイルの中だけの宣言になります。実際に import を1行足したところ拡張が効かなくなり、元の TS18048 が出ました。

plain.ts(2,20): error TS18048: 'url' is possibly 'undefined'.

モジュールとして書きたい場合は、declare global で囲みます。この形でも tsc がエラーなしで通ることを確かめました。

// env.d.ts(モジュール形式)
export {};

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      readonly NODE_ENV: "development" | "production" | "test";
      readonly DATABASE_URL: string;
      readonly PORT?: string;
    }
  }
}

型を足したあとの tsc の反応

// typed2.ts
const port = process.env.PORT;
console.log(port.length);
if (process.env.NODE_ENV === "staging") {
  console.log("staging");
}
console.log(process.env.DATABSE_URL.length);
const n: number = process.env.DATABASE_URL;
$ npx tsc -p .
typed2.ts(2,13): error TS18048: 'port' is possibly 'undefined'.
typed2.ts(3,5): error TS2367: This comparison appears to be unintentional because the types '"development" | "production" | "test"' and '"staging"' have no overlap.
typed2.ts(6,13): error TS18048: 'process.env.DATABSE_URL' is possibly 'undefined'.
typed2.ts(7,7): error TS2322: Type 'string' is not assignable to type 'number'.
行 何が起きたか
2行目 PORT? と書いたので undefined の可能性を指摘される
3行目 NODE_ENV の候補にない値との比較を TS2367 で指摘される
6行目 綴りを間違えた DATABSE_URL は「存在しない」とはならず、既定の string | undefined として扱われる
7行目 DATABASE_URL は string になったので、number には代入できない

6行目が示すとおり、型を足しても、定義していない名前は引き続き読めます。元の辞書型(インデックスシグネチャ)が残っているためです。綴りの間違いを見つけたいときは、noPropertyAccessFromIndexSignature を有効にすると、定義していない名前へのドット記法が TS4111 になります。

$ npx tsc -p . --noPropertyAccessFromIndexSignature
idx.ts(1,25): error TS4111: Property 'DATABSE_URL' comes from an index signature, so it must be accessed with ['DATABSE_URL'].

env.d.ts に number と書いた場合

readonly PORT: number; と書くと、元の辞書型(値は string | undefined)と合わないため、.d.ts 自体が TS2411 になります。

env.d.ts(3,14): error TS2411: Property 'PORT' of type 'number' is not assignable to 'string' index type 'string | undefined'.

一方、tsconfig.json で skipLibCheck: true にしていると .d.ts の検査が省かれ、このエラーは出ません。その状態で次のコードを動かすと、型の上では数値の足し算でも、実行時は文字列の連結になります。

// a.ts
const next = process.env.PORT + 1;
console.log(next);
$ npx tsc -p .   (skipLibCheck: true。エラーなし)
$ PORT=8080 node a.ts
80801

.d.ts では string と書き、数値への変換はコード側で行うのが、型と実行時の値を一致させる書き方です。

型を足しても実行時は undefined になりうる

ここがこの記事でいちばん伝えたい点です。先ほどの env.d.ts を置いた状態で、次のコードを確認します。

// plain.ts
const url = process.env.DATABASE_URL;
console.log("長さ:", url.length);

tsc はエラーなしで通ります(終了コード 0)。ところが、DATABASE_URL を設定せずに実行すると止まります。

$ npx tsc -p .
$ echo $?
0

$ node plain.ts
plain.ts:2
console.log("長さ:", url.length);
                       ^

TypeError: Cannot read properties of undefined (reading 'length')

Node.js v22.22.2

$ DATABASE_URL=postgres://localhost/app node plain.ts
長さ: 24
確認 結果 理由
tsc(型チェック) エラーなし env.d.ts が「DATABASE_URL は string」と宣言しているため
node(変数なし) TypeError で停止 実行時の process.env に値が入っていないため
node(変数あり) 長さ: 24 値が入っていれば型どおりに動く

.d.ts は「こういう値が入っているはず」という宣言であり、値を用意したり確かめたりはしません。 型を足す方法は補完と綴りの確認に役立ち、値が本当にあるかどうかは実行時の検証で確かめる、という役割分担になります。

import.meta.env に型を足す(ImportMetaEnv の拡張)

Vite では、VITE_ で始まる環境変数が import.meta.env から文字列として読めます。型は vite/client が提供しており、独自の変数は既定で any です。

// node_modules/vite/types/importMeta.d.ts より(Vite 8.3.2)
type ImportMetaEnvFallbackKey =
  'strictImportMetaEnv' extends keyof ViteTypeOptions ? never : string

interface ImportMetaEnv extends Record<ImportMetaEnvFallbackKey, any> {
  BASE_URL: string
  MODE: string
  DEV: boolean
  PROD: boolean
  SSR: boolean
}

コピペ用の vite-env.d.ts

// src/vite-env.d.ts
interface ViteTypeOptions {
  // この行を有効にすると、定義していない名前がエラーになる
  strictImportMetaEnv: unknown;
}

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_PAGE_SIZE: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
{
  "compilerOptions": {
    "target": "es2023",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

Vite の公式ドキュメント(Env Variables and Modes)には、この拡張が効かないときは vite-env.d.ts に import 文がないか確かめるように、という注意書きがあります。process.env のときと同じ理由です。

strictImportMetaEnv の有無で比べる

// src/main.ts
const apiUrl: string = import.meta.env.VITE_API_URL;
console.log("MODE:", import.meta.env.MODE);
console.log("VITE_API_URL:", apiUrl);
console.log("VITE_TYPO:", import.meta.env.VITE_TYPO);
console.log("大文字:", apiUrl.toUpperCase());
ViteTypeOptions の設定 tsc の結果
strictImportMetaEnv なし エラーなし(VITE_TYPO は any として通る)
strictImportMetaEnv: unknown あり 下の TS2339
src/main.ts(4,43): error TS2339: Property 'VITE_TYPO' does not exist on type 'ImportMetaEnv'.

ビルド結果でも undefined になる

VITE_TYPO の行を消し、.env を用意せずにビルドして実行しました。Vite はビルド時に import.meta.env.VITE_API_URL を値へ置き換えるので、未設定の変数は undefined(出力では void 0)になります。

// dist/main.js(vite build の出力)
var apiUrl = void 0;
console.log("MODE:", "production");
console.log("VITE_API_URL:", apiUrl);
console.log("大文字:", apiUrl.toUpperCase());
$ node dist/main.js
MODE: production
VITE_API_URL: undefined
dist/main.js:5
console.log("大文字:", apiUrl.toUpperCase());
                           ^

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

.env に VITE_API_URL=https://api.example.com を書いてビルドし直すと、次のように動きます。

MODE: production
VITE_API_URL: https://api.example.com
大文字: HTTPS://API.EXAMPLE.COM

import.meta.env でも事情は同じで、型は string でも値が入るかどうかはビルド時の設定次第です。

3方式を同じ条件で動かして比べる

DATABASE_URL(必須)と PORT(省略時は 3000、整数)を読む処理を、3方式で書いて比べます。

方式1:型を拡張するだけ

// m1-types.ts(env.d.ts を置いた状態)
const url: string = process.env.DATABASE_URL;
const port: number = Number(process.env.PORT);
console.log({ url, port });
console.log("接続先の長さ:", url.length);

方式2:起動時に自前で検証する

// m2-manual.ts
function requireEnv(name: string): string {
  const value = process.env[name];
  if (value === undefined || value === "") {
    throw new Error(`環境変数 ${name} が設定されていません`);
  }
  return value;
}

function intEnv(name: string, fallback?: number): number {
  const raw = process.env[name];
  if (raw === undefined || raw === "") {
    if (fallback !== undefined) return fallback;
    throw new Error(`環境変数 ${name} が設定されていません`);
  }
  const n = Number(raw);
  if (!Number.isInteger(n)) {
    throw new Error(`環境変数 ${name} は整数で指定してください(受け取った値: "${raw}")`);
  }
  return n;
}

export const env = {
  DATABASE_URL: requireEnv("DATABASE_URL"),
  PORT: intEnv("PORT", 3000),
} as const;

console.log(env);

env の型は { readonly DATABASE_URL: string; readonly PORT: number } になります。ほかのファイルは process.env ではなくこの env を import して使うと、検証済みの値だけが流れる形になります。

方式3:zod で検証する

// m3-zod.ts
import * as z from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  DATABASE_URL: z.string().min(1),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("環境変数の設定を確認してください");
  console.error(z.prettifyError(parsed.error));
  process.exit(1);
}

export const env = parsed.data;
export type Env = z.infer<typeof EnvSchema>;

console.log(env);

スキーマから型(Env)が作られるので、env.d.ts を別に書く必要がなくなります。型と検証の定義が1か所にまとまるのが利点です。

ケース別の出力

3つとも tsc はエラーなしで通ります。違いは実行時に出ます。

ケース1:DATABASE_URL が未設定

$ node m1-types.ts
{ url: undefined, port: NaN }
m1-types.ts:5
console.log("接続先の長さ:", url.length);
                           ^
TypeError: Cannot read properties of undefined (reading 'length')

$ node m2-manual.ts
Error: 環境変数 DATABASE_URL が設定されていません

$ node m3-zod.ts
環境変数の設定を確認してください
✖ Invalid input: expected string, received undefined
  → at DATABASE_URL

ケース2:DATABASE_URL が空文字

$ DATABASE_URL= node m1-types.ts
{ url: '', port: NaN }
接続先の長さ: 0

$ DATABASE_URL= node m2-manual.ts
Error: 環境変数 DATABASE_URL が設定されていません

$ DATABASE_URL= node m3-zod.ts
環境変数の設定を確認してください
✖ Too small: expected string to have >=1 characters
  → at DATABASE_URL

ケース3:PORT が数値に変換できない(PORT=abc)

$ DATABASE_URL=postgres://localhost/app PORT=abc node m1-types.ts
{ url: 'postgres://localhost/app', port: NaN }
接続先の長さ: 24

$ DATABASE_URL=postgres://localhost/app PORT=abc node m2-manual.ts
Error: 環境変数 PORT は整数で指定してください(受け取った値: "abc")

$ DATABASE_URL=postgres://localhost/app PORT=abc node m3-zod.ts
環境変数の設定を確認してください
✖ Invalid input: expected number, received NaN
  → at PORT

ケース4:PORT が空文字(PORT=)

$ DATABASE_URL=postgres://localhost/app PORT= node m1-types.ts
{ url: 'postgres://localhost/app', port: 0 }
接続先の長さ: 24

$ DATABASE_URL=postgres://localhost/app PORT= node m2-manual.ts
{ DATABASE_URL: 'postgres://localhost/app', PORT: 3000 }

$ DATABASE_URL=postgres://localhost/app PORT= node m3-zod.ts
環境変数の設定を確認してください
✖ Too small: expected number to be >=1
  → at PORT

ケース5:複数の問題が同時にある(PORT=80.5、NODE_ENV=staging)

$ DATABASE_URL=postgres://localhost/app PORT=80.5 NODE_ENV=staging node m3-zod.ts
環境変数の設定を確認してください
✖ Invalid option: expected one of "development"|"production"|"test"
  → at NODE_ENV
✖ Invalid input: expected int, received number
  → at PORT

方式2と方式3は、検証で止まったときの終了コードが 1 になることも確かめました。

結果の比較表

ケース 方式1:型の拡張だけ 方式2:自前の検証 方式3:zod
DATABASE_URL 未設定 値を使った行で TypeError 起動時に「設定されていません」 起動時に expected string, received undefined
DATABASE_URL が空文字 そのまま動く(長さ 0) 起動時に「設定されていません」 起動時に Too small(min(1) による)
PORT=abc NaN のまま動く 起動時に「整数で指定してください」 起動時に expected number, received NaN
PORT=(空文字) 0 になって動く 既定値 3000 を使う 0 に変換され min(1) で止まる
複数の問題 最初に使った箇所だけ 最初の1件で止まる 全件を一度に表示
正しい値(PORT=8080) 8080 8080 8080

読み取れることは次のとおりです。

  • Number("") は 0、Number("abc") は NaN になります。方式1ではどちらもエラーにならず、そのまま先へ進みます。
  • zod の .default() は値が undefined のときだけ働きます。PORT= のように空文字が入っていると既定値にはならず、z.coerce.number() が 0 に変換します。今回は min(1) があるので起動時に気づけました。
  • 自前の検証は、空文字を「未設定と同じ」とみなすかどうかを自分で決められます。

import.meta.env を zod で検証する

Vite でも同じ考え方が使えます。検証用のモジュールを1つ作り、ほかのファイルはそこから読みます。

// src/env.ts
import * as z from "zod";

const EnvSchema = z.object({
  VITE_API_URL: z.url(),
  VITE_PAGE_SIZE: z.coerce.number().int().positive().default(20),
});

export const env = EnvSchema.parse(import.meta.env);
// src/main.ts
import { env } from "./env";

console.log(env);

.env なしでビルドして実行すると、読み込み時点で止まります。

ZodError: [
  {
    "expected": "string",
    "code": "invalid_type",
    "path": [
      "VITE_API_URL"
    ],
    "message": "Invalid input: expected string, received undefined"
  }
]

VITE_API_URL=https://api.example.com だけを設定した場合は、VITE_PAGE_SIZE に既定値が入ります。

{ VITE_API_URL: 'https://api.example.com', VITE_PAGE_SIZE: 20 }

import.meta.env の値はビルド時に埋め込まれるため、この検証で気づけるのは「ビルドしたときに値がなかった」ことです。画面を開いた直後にわかるので、原因をたどる時間が短くなります。

どの方式を選ぶかの判断表

状況 向いている方式
変数が1〜2個の小さなスクリプト 方式2(requireEnv を数行書く)
補完と綴りの確認だけ先に入れたい 方式1(あとから検証を足せる)
変数が多い、数値・URL・列挙値が混ざる 方式3(zod)
依存パッケージを増やしたくない 方式2
設定の不足を一度にまとめて知らせたい 方式3(全件表示)
Vite のフロントエンド vite-env.d.ts + 必要に応じて方式3

方式1と方式2・3は組み合わせられます。ただし、検証済みの env オブジェクトを用意したら、アプリのコードからは process.env を直接読まず env だけを使うと、型と実行時の値が食い違う場面がなくなります。

つまずきやすい点

症状 原因 対応
TS2591 Cannot find name 'process' tsconfig.json の types に node がない @types/node を入れ、"types": ["node"] を足す
.d.ts を置いたのに TS18048 が消えない .d.ts に import / export がある、または include の対象外 declare global で囲む。include を確かめる
TS2411、または計算結果が文字列の連結になる .d.ts に PORT: number と書いた(skipLibCheck: true だと TS2411 は出ない) 型は string にして Number() や zod で変換する
綴り間違いがエラーにならない 元のインデックスシグネチャが残っている noPropertyAccessFromIndexSignature、Vite は strictImportMetaEnv
tsc は通るのに実行時に undefined 型定義は値を確かめない 起動時に検証する(方式2・3)
PORT= で既定値が入らない 空文字は undefined ではない 空文字の扱いを検証側で決める

よくある質問

Q. process.env に型を足せば、undefined の確認は不要になりますか? A. 型チェックの上では不要になりますが、実行時の値は変わりません。この記事の確認でも、tsc がエラーなしで通ったコードが、変数を設定せずに実行すると TypeError で止まりました。値があることを保証したい変数は、起動時に検証します。

Q. env.d.ts に PORT: number と書いてもよいですか? A. TypeScript 7.0.2 では .d.ts が TS2411 になりました。skipLibCheck: true の設定ではエラーが出ませんが、実行時の値は文字列のままなので、PORT=8080 のとき process.env.PORT + 1 は 80801 になります。.d.ts では string と書き、変換は Number() や z.coerce.number() で行うのがおすすめです。

Q. process.env.X! や as string で済ませるのはどうですか? A. どちらも「値があるはず」と型に伝えるだけで、実行時の確認は行いません。効果は .d.ts で string と宣言するのと同じです。未設定に早く気づきたい場合は、検証関数を通した値を使います。

Q. zod と自前の検証はどちらがよいですか? A. 変数が少なければ自前の関数で十分です。数値の範囲、URL の形式、決まった候補からの選択などが増えてきたら、zod のほうが短く書けて、問題を一度にまとめて表示できます。

まとめ

  • process.env.X の既定の型は string | undefined、Vite の import.meta.env の独自変数は any
  • NodeJS.ProcessEnv と ImportMetaEnv を .d.ts で拡張すると、補完と型チェックが効く
  • .d.ts に import があるときは declare global で囲む
  • 型定義は実行時の値を変えない。tsc が通っても、未設定なら undefined
  • Number("") は 0、Number("abc") は NaN。型の拡張だけでは素通りする
  • 起動時の検証(自前の関数か zod)で、未設定・空文字・変換できない値を早く見つける
  • 検証済みの env オブジェクトを1つ作り、アプリからはそれだけを読む

参考

  • Vite 公式ドキュメント「Env Variables and Modes」(IntelliSense for TypeScript の節): https://vite.dev/guide/env-and-mode

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript環境変数の型|process.envとundefined」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?