💎

Next.jsで環境変数を検証して型安全に扱う

2022/10/21に公開約5,800字2件のコメント

Next.js において、環境変数 process.env を検証することで型安全に扱えるようにする記事です。

↓こんな感じで環境変数を使えるようにします。

クライアント側
import { clientEnv } from '../env/client'

const validUrl = clientEnv.NEXT_PUBLIC_SITE_URL // string型。url形式の値であることが検証済み
const invalidUrl = clientEnv.NEXT_PUBLIC_SITE_URK // タイポはコンパイルエラーになる
サーバー側
import { serverEnv } from '../env/server'

const validToken = serverEnv.SECRET_TOKEN // string型。英数字32文字の値であることが検証済み
const invalidToken = serverEnv.SECRET_TOKEM // タイポはコンパイルエラーになる

// clientEnv に含まれる環境変数も取得できる
const validUrl = serverEnv.NEXT_PUBLIC_SITE_URL // string型。url形式の値であることが検証済み

process.env の問題点

例えば、process.env.NEXT_PUBLIC_SITE_URL という環境変数を使いたい場合、下記のような問題が考えられます。

  • 型が string | undefined になる。(設定されていることを前提にする場合がほとんどなので、string 型になっていて欲しい)
  • process.env.NEXT_PUBLIC_SITE_URK の様にタイポした場合、間違っていることに気づけずランタイムエラー等が発生する可能性がある。
  • 環境変数に間違った値を設定した場合(例えば http:// とするところを http// にしてしまった等)、間違っていることに気づけずランタイムエラー等が発生する可能性がある。
  • 環境変数を設定すること自体を忘れた場合、ランタイムエラー等が発生する可能性がある。

最悪の場合、環境変数の設定をし忘れてデプロイしてしまい障害が起こる、なんてことが起きそうです。

解決策

上記の問題点を解決するために、以下のような仕組みを作ります。

  • string | undefined ではなく、string 型の値を取得できるようにする。
  • 変数のタイポに気づけるようにする。
  • 環境変数の設定漏れや形式誤りをビルド時に気づけるようにする。

今回は検証用ライブラリ Zod を用いて環境変数をビルド時に事前検証しておくことで、正しい形式で値が設定されていることを保証し、型安全に環境変数を使用できるようにします。

Zod をインストール

npm install zod

Zodスキーマを定義

環境変数を検証するためのZodスキーマを定義する schema.js ファイルを作成します。

呼び出し側で使いやすくするためにサーバー側とクライアント側で分けてスキーマを定義します。

/env/schema.js
// @ts-check
const { z } = require('zod')

/**
 * サーバー側で使う環境変数のスキーマを定義
 */
const serverSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  SECRET_TOKEN: z.string().regex(/[a-zA-Z0-9]{32}/), // 32文字英数字
})

/**
 * クライアント側で使う環境変数のスキーマを定義
 * クライアント側に公開するには、`NEXT_PUBLIC_` プレフィックスをつける
 */
const clientSchema = z.object({
  NEXT_PUBLIC_SITE_URL: z.string().url(),
})

/** 
 * クライアント側で使う環境変数を定義
 * @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}
 */
const clientEnv = {
  NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
}

// エクスポート
module.exports = {
  serverSchema,
  clientSchema,
  clientEnv,
}

ちなみにスキーマ定義で transform を使うことで、環境変数を他の型に変換して取得することもできます。(例えば "2"2 に変換して number 型として取得)

環境変数を検証する

上記で作成したスキーマを読み込み、使用する環境変数の検証をするファイルを作成します。
わかりやすいよう、クライアント側 client.js とサーバー側 server.js でそれぞれ分けて作成します。(.js で作成する理由は schema.js 同様です。)

検証に失敗した場合は process.exit(1) で強制終了し、ビルド時にエラーに気づくことができるようにします。

/env/client.js
// @ts-check
const { clientEnv, clientSchema } = require('./schema')

// クライアント側で使う環境変数を検証
const _clientEnv = clientSchema.safeParse(clientEnv)

// 検証に失敗した場合はビルドエラーにする
if (!_clientEnv.success) {
  console.error(
    '❌ Invalid public environment variables:',
    JSON.stringify(_clientEnv.error.format(), null, 4)
  )
  process.exit(1)
}

// `NEXT_PUBLIC_` で始まらない環境変数名がある場合はビルドエラーにする
for (let key of Object.keys(_clientEnv.data)) {
  if (!key.startsWith('NEXT_PUBLIC_')) {
    console.error(
      `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`
    )
    process.exit(1)
  }
}

// 検証済みの値をエクスポート
module.exports.clientEnv = _clientEnv.data
/env/server.js
// @ts-check
const { clientEnv } = require('./client.js')
const { serverSchema } = require("./schema")

// サーバー側で使う環境変数を検証
const _serverEnv = serverSchema.safeParse(process.env)

// 検証に失敗した場合はビルドエラーにする
if (!_serverEnv.success) {
  console.error(
    '❌ Invalid server environment variables:',
    JSON.stringify(_serverEnv.error.format(), null, 4)
  )
  process.exit(1)
}

// クライアント側用に定義した値も使用できるようマージしてエクスポート
module.exports.serverEnv = { ..._serverEnv.data, ...clientEnv }

ビルド時に検証できるようにする

上記で作成した /env/server.jsnext.config.js から読み込ませます。
こうすることで、ビルド時に検証が行われるようになります。

/next.config.js
// @ts-check
const { serverEnv } = require('./env/server')
// 以下略

検証済みの環境変数を使う

クライアント側からは /env/client.js を、サーバー側からは /env/server.js をインポートして使うようにします。

クライアント側の例 /pages/index.tsx
import { clientEnv } from '../env/client'

const Home = () => {
  const validUrl = clientEnv.NEXT_PUBLIC_SITE_URL // string型。url形式の値であることが検証済み
  const invalidUrl = clientEnv.NEXT_PUBLIC_SITE_URK // タイポはコンパイルエラーになる。サジェストが効くのでタイポしなくなる
  
  return (
    <div>{validUrl}</div>
  )
}
サーバー側の例 /pages/api/hello.ts
import { serverEnv } from '../../env/server'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const validValue = serverEnv.SECRET_TOKEN // string型。英数字32文字の値であることが検証済み
  const invalidValue = serverEnv.SECRET_TOKEM // タイポはコンパイルエラーになる。サジェストが効くのでタイポしなくなる

  // クライアント側用に定義した変数も使える
  const validUrl = serverEnv.NEXT_PUBLIC_SITE_URL // string型。url形式の値であることが検証済み

  res.status(200).json({ name: "John Doe" });
}

string型の値を取得できるようになり、タイポにも気づけるように(&サジェストで候補が表示されるのでタイポしないように)なりました🎉🎉🎉

ビルドしてみる

あえて NEXT_PUBLIC_SITE_URL の環境変数にURLの形式ではない値を設定してビルドしてみましょう。ビルドが失敗することを確認できるはずです。

$ npm run build

...略...

❌ Invalid public environment variables: {
    "_errors": [],
    "NEXT_PUBLIC_SITE_URL": {
        "_errors": [
            "Invalid url"
        ]
    }
}
error Command failed with exit code 1.

全ての環境変数に正しい値を設定するとビルドが通るようになります。

これで環境変数の設定漏れや間違った形式での設定が防げるようになりました🎉🎉🎉

最後に

この記事は Next.js 向けでしたが、Node.js であれば似たような感じで環境変数を事前検証する仕組みを作れると思います。

誰かの参考になると幸いです。

Discussion

tRPC 作者 KATT 氏の envsafe というライブラリも、ほぼ同様のモチベーションのもとに作られており、加えてより宣言的に記述できる(値を検証してエラーメッセージを出してプロセスを終了させる、という流れを記述する必要がない)という利点もあったりして、この手のことをおこなう際の参考になるかもしれません!

https://github.com/KATT/envsafe

なんと!より便利そうですね、ありがとうございます!

ログインするとコメントできます