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 のアーキテクチャは四つの中核抽象に基づいています:
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 が必要です。
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 で取引を送信します:
// 組み合わせ使用:読み取り + 書き込み
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 に加えて、開発者はカスタム定義も可能です:
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 は完全なパラメータと戻り値の型ヒントを提供します:
// 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 などのユーティリティ型を使用して、特定の関数のパラメータ型を抽出することもサポートします:
import { type GetAbiFunctionParameters } from 'viem'
type TransferArgs = GetAbiFunctionParameters<typeof wethAbi, 'transfer'>
// { to: Address; amount: bigint }
ethers.js との API 比較
以下の表は一般的な操作を比較しています:
| 操作 | ethers.js v6 | viem |
|---|---|---|
| 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 インタラクション層の実装で、コントラクト読み取り、書き込み、イベント監視、エラーハンドリングをカバーしています:
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 v6 | viem v2 | 節約 |
|---|---|---|---|
| 読み取り専用コントラクト呼び出し | 48.2 KB | 11.4 KB | 76% |
| 読み書きコントラクト呼び出し | 72.8 KB | 23.1 KB | 68% |
| 完全機能 | 135.4 KB | 45.7 KB | 66% |
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 は基盤インタラクションを提供します:
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 をインストールして並行使用
npm install viem
新機能は viem で記述し、旧コードは ethers.js のまま維持します。両者は同じ RPC エンドポイントを共有でき、互いに干渉しません。
フェーズ二:インタラクション層を抽象化
ethers.js を直接使用しているコードを抽象化層経由の呼び出しにリファクタリングします:
// 以前
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 実装に置き換えます:
// 以前
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 インタラクション層を構築する基盤です。
