Skip to content

Viem 深入:型安全な Ethereum インタラクション層

viem 設計哲学:tree-shakable + 型安全 + 依存なし ​

viem は wagmi チームによって構築された Ethereum インタラクションライブラリであり、その設計哲学は三つのキーワードで要約できます:

  • Tree-shakable:すべての機能が独立した関数で実装され、パッケージング時に使用する部分のみが含まれます。コントラクト読み取りのみの DApp では、viem のパッケージサイズは 20KB まで抑えられます
  • 型安全:TypeScript の型システムに基づき、viem は ABI から関数名、パラメータ型、戻り値型を自動的に推論でき、追加のコード生成ツールは不要です
  • ランタイム依存なし:viem は他のライブラリに依存せず(viem 自身の内部パッケージを除く)、依存ツリーの複雑さとバージョン競合リスクを低減します

ethers.js の「統一的」設計とは異なり、viem は Unix 哲学を選択しました——各関数が一つのことをうまくやり、組み合わせて使用します。この設計により、viem は軽量かつ柔軟です。

中核概念:Client, Transport, Chain, Account ​

viem のアーキテクチャは四つの中核抽象に基づいています:

typescript
import { createClient, http, parseEther } from 'viem'
import { mainnet } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'

// 1. Chain — チェーンのパラメータを定義(chainId, RPC, ブロックエクスプローラなど)
const chain = mainnet

// 2. Transport — 基盤通信層(HTTP, WebSocket, カスタム)
const transport = http('https://eth.llamarpc.com')

// 3. Account — 署名アカウント(オプション、PublicClient では不要)
const account = privateKeyToAccount('0x...')

// 4. Client — 上記三つを組み合わせ、基本機能を提供
const client = createClient({
  chain,
  transport,
  // account はオプション:account があれば WalletClient、なければ PublicClient
})

Client 自体は最も基本的な機能(getBlockNumber など)のみを提供します。高度な機能は Actions を通じて拡張されます。この継承よりも合成を優先する設計により、tree-shaking は使用する機能のみを正確にパッケージ化できます。

PublicClient vs WalletClient ​

PublicClient はオンチェーンデータの読み取りに使用され、秘密鍵は不要です。WalletClient は取引の送信と署名に使用され、Account が必要です。

typescript
import { createPublicClient, createWalletClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'

// PublicClient — 読み取り専用
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

// ブロック番号を読み取り
const blockNumber = await publicClient.getBlockNumber()

// コントラクトデータを読み取り
const balance = await publicClient.readContract({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: ['0xd8da6bf26964af9d7eed9e03e53415d37aa96045'],
})

// WalletClient — 書き込み可
const walletClient = createWalletClient({
  chain: mainnet,
  transport: http(),
  account: privateKeyToAccount('0x...'),
})

// 取引を送信
const hash = await walletClient.sendTransaction({
  to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
  value: parseEther('1'),
})

よくあるパターンは両方を同時に使用することです:PublicClient で状態を読み取り、WalletClient で取引を送信します:

typescript
// 組み合わせ使用:読み取り + 書き込み
const { request } = await publicClient.simulateContract({
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [recipient, amount],
  account: walletClient.account,
})

const hash = await walletClient.writeContract(request)

Actions パターン:readContract, writeContract, getBlock ​

viem の Actions は独立した純粋関数であり、client パラメータを通じてクライアントインスタンスにバインドされます。組み込みの Actions に加えて、開発者はカスタム定義も可能です:

typescript
import { type Client, getBlock, readContract } from 'viem'

// 方法一:Action 関数を直接呼び出す
const block = await getBlock(publicClient, { blockTag: 'latest' })
const data = await readContract(publicClient, { ... })

// 方法二:Client を拡張(推奨、型がより親切)
const extendedClient = publicClient.extend((client) => ({
  async getLatestGasPrice() {
    const block = await getBlock(client)
    return block.baseFeePerGas
  },
  async getTokenBalance(token: Address, holder: Address) {
    return readContract(client, {
      address: token,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [holder],
    })
  },
}))

// 拡張されたメソッドを使用
const gasPrice = await extendedClient.getLatestGasPrice()
const balance = await extendedClient.getTokenBalance(usdcAddress, userAddress)

この extend パターンは viem の真髄です——型安全な方法でビジネスロジックをカプセル化しながら、tree-shaking 能力を犠牲にしません。

組み込み ABI 型推論:TypeChain 不要 ​

viem の最も強力な特性の一つは、ABI から自動的に型を推論することです。ABI の TypeScript 型定義が正しければ、readContract と writeContract は完全なパラメータと戻り値の型ヒントを提供します:

typescript
// ABI を定義(as const に注意)
const wethAbi = [
  {
    type: 'function',
    name: 'deposit',
    stateMutability: 'payable',
    inputs: [],
    outputs: [{ type: 'uint256' }],
  },
  {
    type: 'function',
    name: 'balanceOf',
    stateMutability: 'view',
    inputs: [{ type: 'address', name: 'account' }],
    outputs: [{ type: 'uint256' }],
  },
  {
    type: 'function',
    name: 'transfer',
    stateMutability: 'nonpayable',
    inputs: [
      { type: 'address', name: 'to' },
      { type: 'uint256', name: 'amount' },
    ],
    outputs: [{ type: 'bool' }],
  },
] as const  // キー:as const により TypeScript が正確な型を推論

// readContract が自動推論:
// - functionName は 'deposit' | 'balanceOf' | 'transfer' のみ
// - args 型は functionName に応じて自動マッチング
// - 戻り値型は functionName に応じて自動推論
const balance = await publicClient.readContract({
  address: wethAddress,
  abi: wethAbi,
  functionName: 'balanceOf',
  args: [userAddress],
  //    ^? [Address] — 型が正確に推論される
})
//    ^? bigint — 戻り値も正確な型

const hash = await walletClient.writeContract({
  address: wethAddress,
  abi: wethAbi,
  functionName: 'transfer',
  args: [recipient, parseEther('1')],
  //    ^? [Address, bigint]
})

これにより TypeChain や他のコード生成ツールへの依存が解消されます。ABI は実行時データであると同時に、コンパイル時の型ソースでもあります。

viem はまた GetAbiFunctionParameters などのユーティリティ型を使用して、特定の関数のパラメータ型を抽出することもサポートします:

typescript
import { type GetAbiFunctionParameters } from 'viem'

type TransferArgs = GetAbiFunctionParameters<typeof wethAbi, 'transfer'>
// { to: Address; amount: bigint }

ethers.js との API 比較 ​

以下の表は一般的な操作を比較しています:

操作ethers.js v6viem
Provider 作成new ethers.JsonRpcProvider(url)createPublicClient({ chain, transport: http(url) })
コントラクト読み取りcontract.balanceOf(addr)publicClient.readContract({ ... })
取引送信contract.transfer(to, amount)walletClient.writeContract({ ... })
型推論TypeChain 生成が必要組み込み、ABI as const
イベント監視contract.on('Transfer', cb)publicClient.watchEvent({ ... })
パッケージサイズ~100KB+~20KB(必要に応じて導入)

ethers.js の Contract オブジェクトパターン(メソッドがオブジェクトにマウントされる)は、TypeScript 型推論において本質的な限界があります。viem の明示的な functionName パラメータパターンと as const ABI の組み合わせは、型安全性において ethers.js を大きく上回ります。

viem で完全な DApp インタラクション層を実装 ​

以下は完全な DApp インタラクション層の実装で、コントラクト読み取り、書き込み、イベント監視、エラーハンドリングをカバーしています:

typescript
import {
  createPublicClient,
  createWalletClient,
  http,
  type Address,
  type Hash,
  type Log,
  type Abi,
  parseEventLogs,
  decodeEventLog,
} from 'viem'
import { mainnet } from 'viem/chains'

// DApp インタラクション層設定
interface DAppConfig {
  chainId: number
  rpcUrl: string
  wsUrl?: string
  contracts: Record<string, { address: Address; abi: Abi }>
}

class DAppInteractionLayer {
  private publicClient: PublicClient
  private walletClient: WalletClient | null = null
  private config: DAppConfig

  constructor(config: DAppConfig) {
    this.config = config
    const chain = getChainByChainId(config.chainId)

    this.publicClient = createPublicClient({
      chain,
      transport: http(config.rpcUrl),
    })
  }

  // ウォレット接続
  connectWallet(account: Address, transport: Transport) {
    const chain = getChainByChainId(this.config.chainId)
    this.walletClient = createWalletClient({
      chain,
      transport,
      account,
    })
  }

  // 汎用コントラクト読み取り
  async read<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    functionName: string,
    args: readonly unknown[],
  ) {
    const { address, abi } = this.config.contracts[contractName]
    return this.publicClient.readContract({
      address,
      abi,
      functionName,
      args: args as any,
    })
  }

  // 汎用コントラクト書き込み(シミュレーション + 送信を含む)
  async write<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    functionName: string,
    args: readonly unknown[],
    options?: { value?: bigint },
  ) {
    if (!this.walletClient) throw new Error('Wallet not connected')

    const { address, abi } = this.config.contracts[contractName]

    // 先にシミュレーション実行して取引が成功するか検証
    const { request } = await this.publicClient.simulateContract({
      address,
      abi,
      functionName,
      args: args as any,
      account: this.walletClient.account,
      value: options?.value,
    })

    // 取引を送信
    const hash = await this.walletClient.writeContract(request)

    // 確認待ち
    const receipt = await this.publicClient.waitForTransactionReceipt({ hash })

    return { hash, receipt, logs: receipt.logs }
  }

  // イベントログを解析
  parseEvents<TAbi extends Abi>(
    logs: Log[],
    abi: TAbi,
    eventName?: string,
  ) {
    const parsed = parseEventLogs({ logs, abi })
    return eventName ? parsed.filter((l) => l.eventName === eventName) : parsed
  }

  // コントラクトイベントを監視
  watchEvent<TContractName extends keyof typeof this.config.contracts>(
    contractName: TContractName,
    eventName: string,
    onLog: (log: any) => void,
    fromBlock?: bigint,
  ) {
    const { address, abi } = this.config.contracts[contractName]
    return this.publicClient.watchEvent({
      address,
      event: getEventFromAbi(abi, eventName),
      onLogs: (logs) => logs.forEach(onLog),
      fromBlock,
    })
  }

  // バッチ読み取り(Multicall)
  async multicall(
    calls: { contractName: string; functionName: string; args: readonly unknown[] }[],
  ) {
    const multicallData = calls.map((call) => {
      const { address, abi } = this.config.contracts[call.contractName]
      return {
        address,
        abi,
        functionName: call.functionName,
        args: call.args as any,
      }
    })

    return this.publicClient.multicall({ contracts: multicallData })
  }
}

// 補助関数
function getChainByChainId(chainId: number) {
  // 実際のプロジェクトでは viem/chains から対応するチェーンをインポート
  return mainnet
}

function getEventFromAbi(abi: Abi, eventName: string) {
  const item = abi.find((entry) => entry.type === 'event' && entry.name === eventName)
  if (!item) throw new Error(`Event ${eventName} not found in ABI`)
  return item as any
}

export { DAppInteractionLayer }
export type { DAppConfig }

パフォーマンス比較:bundle size と実行効率 ​

viem は bundle size において ethers.js を大幅に上回ります。以下は実測比較(minified + gzipped)です:

シナリオethers.js v6viem v2節約
読み取り専用コントラクト呼び出し48.2 KB11.4 KB76%
読み書きコントラクト呼び出し72.8 KB23.1 KB68%
完全機能135.4 KB45.7 KB66%

viem の優位性は tree-shaking から来ています——使用する Action 関数のみが導入されます。ethers.js の Contract クラスは大量の未使用コードを導入してしまいます。

実行効率においては、viem は @noble/curves を使用して ethers.js の自前実装暗号ライブラリを置き換えており、署名検証と公開鍵復元において 2-3 倍高速です。ABI エンコード/デコードにおいても、viem の実装はより効率的です。なぜなら、コンパイル時にエンコード/デコードパスが決定され、実行時の動的ルックアップが不要だからです。

wagmi v2 との深い統合 ​

wagmi v2 は完全に viem に基づいて構築されており、両者は自然な組み合わせです。wagmi は React Hooks 層を提供し、viem は基盤インタラクションを提供します:

tsx
import { useAccount, useReadContract, useWriteContract } from 'wagmi'
import { parseEther } from 'viem'
import { mainnet } from 'viem/chains'

function TokenTransfer() {
  const { address } = useAccount()

  // 残高を読み取り
  const { data: balance } = useReadContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [address!],
  })

  // 取引を書き込み
  const { writeContract, isPending, isSuccess, error } = useWriteContract()

  const handleTransfer = () => {
    writeContract({
      address: tokenAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [recipient, parseEther('1')],
    })
  }

  return (
    <div>
      <p>Balance: {balance?.toString()}</p>
      <button onClick={handleTransfer} disabled={isPending}>
        {isPending ? 'Sending...' : 'Transfer'}
      </button>
      {isSuccess && <p>Transaction confirmed!</p>}
      {error && <p>Error: {error.message}</p>}
    </div>
  )
}

wagmi v2 の Hooks は基盤で viem の Actions を呼び出し、型は完全に一貫しています。ABI の型推論は React コンポーネントでも同様に有効です。

移行戦略:ethers v5/v6 から viem へ ​

移行は段階的に行うべきです:

フェーズ一:viem をインストールして並行使用

bash
npm install viem

新機能は viem で記述し、旧コードは ethers.js のまま維持します。両者は同じ RPC エンドポイントを共有でき、互いに干渉しません。

フェーズ二:インタラクション層を抽象化

ethers.js を直接使用しているコードを抽象化層経由の呼び出しにリファクタリングします:

typescript
// 以前
import { ethers } from 'ethers'
const contract = new ethers.Contract(address, abi, signer)
const balance = await contract.balanceOf(user)

// 以後(抽象化層経由)
const balance = await dapp.read('token', 'balanceOf', [user])

フェーズ三:実装を段階的に置き換え

抽象化層の ethers.js 実装を viem 実装に置き換えます:

typescript
// 以前
async read(contract, method, args) {
  const c = new ethers.Contract(address, abi, provider)
  return c[method](...args)
}

// 以後
async read(contract, method, args) {
  return this.publicClient.readContract({
    address, abi, functionName: method, args,
  })
}

フェーズ四:ethers.js を削除

すべての機能が viem に移行されたことを確認したら、ethers.js 依存を削除します。TypeChain を使用して型を生成していた場合も削除する必要があります——viem 組み込みの型推論が完全に代替します。

まとめ ​

viem は Ethereum フロントエンド開発ライブラリの正しい方向性を代表しています:tree-shakable なパッケージング、コンパイル時の型安全性、極小の依存ツリー。その Actions パターンと ABI 型推論は、ethers.js 時代の核心的な問題——パッケージサイズの大きさと型安全性の欠如——を解決します。wagmi v2 の全面的な採用により、viem は React DApp のデファクトスタンダードとなりました。新規プロジェクトでは、迷わず viem を選択すべきです。既存プロジェクトでは、段階的移行戦略がリスクを最小限に抑えます。viem の Client/Transport/Chain/Account の四要素モデルと extend 拡張パターンを理解することが、保守可能な DApp インタラクション層を構築する基盤です。

MIT Licensed