- TypeScript
TypeScript環境変数の型|process.envとundefined
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の独自変数はanyNodeJS.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期連続の黒字決算。