Skip to main content
This guide helps you migrate from viem or ethers.js to voltaire-effect. The migration can be done incrementally—you can use both libraries side by side.

Why Migrate?

voltaire-effect provides key advantages over viem and ethers.js:
Benefitviem/ethersvoltaire-effect
Error handlingTry/catch with unknownTyped errors with ParseError, TransportError
Dependency injectionPass clients everywhereServices via Layer composition
TestabilityMock modules or importsSwap layers with test implementations
CompositionManual promise chainingEffect.gen, pipe, retry, timeout built-in
Type safetyBranded stringsBranded Uint8Array with schemas

Core Concepts

Effect.gen vs async/await

// viem
async function getBlockAndBalance(address: string) {
  const block = await client.getBlockNumber()
  const balance = await client.getBalance({ address })
  return { block, balance }
}

// voltaire-effect
import { getBlockNumber, getBalance } from 'voltaire-effect'

const getBlockAndBalance = (address: AddressType) => Effect.gen(function* () {
  const block = yield* getBlockNumber()
  const balance = yield* getBalance(address)
  return { block, balance }
})
yield* is like await but tracks errors in the type system.

Layer Composition vs Client Configuration

// viem - configuration at creation
const client = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/...')
})

// voltaire-effect - compose layers
const MainLayer = Layer.mergeAll(
  Provider,
  HttpTransport('https://eth-mainnet.g.alchemy.com/v2/...')
)

// Swap for testing
const TestLayer = Layer.mergeAll(
  Provider,
  MockTransport
)

Typed Errors vs Exceptions

// viem - unknown error type
try {
  const balance = await client.getBalance({ address })
} catch (e) {
  // e is unknown - must narrow manually
  if (e instanceof Error) { ... }
}

// voltaire-effect - errors in type signature
import { getBalance } from 'voltaire-effect'

const program = Effect.gen(function* () {
  return yield* getBalance(address)
})
// Effect<bigint, TransportError | ParseError, ProviderService>
//              ↑ typed error channel

program.pipe(
  Effect.catchTag("TransportError", (e) => {
    console.error('RPC failed:', e.message)
    return Effect.succeed(0n)
  })
)

Side-by-Side Comparisons

Getting Block Number

// viem
import { createPublicClient, http } from 'viem'

const client = createPublicClient({ transport: http(rpcUrl) })
const blockNumber = await client.getBlockNumber()

// ethers
import { JsonRpcProvider } from 'ethers'

const provider = new JsonRpcProvider(rpcUrl)
const blockNumber = await provider.getBlockNumber()

// voltaire-effect
import { Effect, Layer } from 'effect'
import { getBlockNumber, Provider, HttpTransport } from 'voltaire-effect'

const program = Effect.gen(function* () {
  return yield* getBlockNumber()
})

const MainLayer = Provider.pipe(Layer.provide(HttpTransport(rpcUrl)))
const blockNumber = await Effect.runPromise(program.pipe(Effect.provide(MainLayer)))

Reading Contract Data

// viem
import { createPublicClient, http, parseAbi } from 'viem'

const client = createPublicClient({ transport: http(rpcUrl) })
const balance = await client.readContract({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  abi: parseAbi(['function balanceOf(address) view returns (uint256)']),
  functionName: 'balanceOf',
  args: [userAddress]
})

// voltaire-effect
import { Effect } from 'effect'
import { readContract, Provider, HttpTransport } from 'voltaire-effect'
import * as Abi from 'voltaire-effect/primitives/Abi'

const program = Effect.gen(function* () {
  return yield* readContract({
    address: contractAddress,
    abi: Abi.parseAbi(['function balanceOf(address) view returns (uint256)']),
    functionName: 'balanceOf',
    args: [userAddress]
  })
})

await Effect.runPromise(program.pipe(Effect.provide(MainLayer)))

Sending Transactions

// viem
import { createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount('0x...')
const client = createWalletClient({ account, transport: http(rpcUrl) })

const hash = await client.sendTransaction({
  to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
  value: parseEther('1')
})

// voltaire-effect
import { Effect, Layer } from 'effect'
import {
  SignerService,
  Signer,
  LocalAccount,
  Provider,
  HttpTransport
} from 'voltaire-effect'
import { Secp256k1Live, KeccakLive } from 'voltaire-effect/crypto'

const program = Effect.gen(function* () {
  const signer = yield* SignerService
  return yield* signer.sendTransaction({
    to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
    value: 1000000000000000000n // 1 ETH in wei
  })
})

// Compose layers with dependencies
const CryptoLayer = Layer.mergeAll(Secp256k1Live, KeccakLive)
const TransportLayer = HttpTransport(rpcUrl)
const ProviderLayer = Provider.pipe(Layer.provide(TransportLayer))

const DepsLayer = Layer.mergeAll(LocalAccount('0x...'), ProviderLayer, CryptoLayer)
const MainLayer = Signer.Live.pipe(Layer.provide(DepsLayer))

const hash = await Effect.runPromise(program.pipe(Effect.provide(MainLayer)))

Event Watching

// viem
const unwatch = client.watchEvent({
  address: contractAddress,
  event: parseAbiItem('event Transfer(address indexed, address indexed, uint256)'),
  onLogs: (logs) => console.log(logs)
})

// voltaire-effect
import { Stream } from 'effect'
import { watchEvent } from 'voltaire-effect'

const watchTransfers = Effect.gen(function* () {
  const stream = yield* watchEvent({
    address: contractAddress,
    event: 'Transfer(address indexed, address indexed, uint256)'
  })

  yield* Stream.runForEach(stream, (log) =>
    Effect.sync(() => console.log(log))
  )
})

Step-by-Step Migration

Step 1: Add Dependencies

pnpm add voltaire-effect effect

Step 2: Create Base Layers

// src/layers.ts
import { Layer } from 'effect'
import { Provider, HttpTransport } from 'voltaire-effect'
import { Secp256k1Live, KeccakLive } from 'voltaire-effect/crypto'

export const MainLayer = Layer.mergeAll(
  Provider,
  HttpTransport(process.env.RPC_URL!),
  Secp256k1Live,
  KeccakLive
)

Step 3: Migrate One Function at a Time

Start with read-only operations before wallet interactions:
// Before: viem
export async function getTokenBalance(token: string, user: string) {
  return client.readContract({
    address: token,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [user]
  })
}

// After: voltaire-effect
import { readContract } from 'voltaire-effect'

export const getTokenBalance = (token: AddressType, user: AddressType) =>
  Effect.gen(function* () {
    return yield* readContract({
      address: token,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [user]
    })
  })

Step 4: Add Error Handling

export const getTokenBalanceSafe = (token: AddressType, user: AddressType) =>
  getTokenBalance(token, user).pipe(
    Effect.retry({ times: 3 }),
    Effect.timeout('10 seconds'),
    Effect.catchTag('TransportError', () => Effect.succeed(0n))
  )

Step 5: Create Test Layers

// src/layers.test.ts
import { Effect, Layer } from 'effect'
import { ProviderService } from 'voltaire-effect'

export const MockProvider = Layer.succeed(ProviderService, {
  request: (method: string, _params?: unknown[]) => {
    switch (method) {
      case 'eth_blockNumber':
        return Effect.succeed('0x3039') // 12345
      case 'eth_getBalance':
        return Effect.succeed('0xde0b6b3a7640000') // 1 ETH
      default:
        return Effect.succeed(null)
    }
  }
})

export const TestLayer = Layer.mergeAll(
  MockProvider,
  Secp256k1Live,
  KeccakLive
)

Common Gotchas

1. Forgetting to Provide Layers

// ❌ Runtime error: missing ProviderService
await Effect.runPromise(program)

// ✅ Always provide required layers
await Effect.runPromise(program.pipe(Effect.provide(MainLayer)))

2. Not Yielding Effects

// ❌ Returns Effect, doesn't execute
Effect.gen(function* () {
  getBlockNumber() // Missing yield*!
})

// ✅ Yield all Effects
Effect.gen(function* () {
  return yield* getBlockNumber()
})

3. Mixing Promises and Effects

// ❌ Breaks Effect error tracking
Effect.gen(function* () {
  const result = await somePromise() // Don't use await!
})

// ✅ Wrap promises with Effect.promise
Effect.gen(function* () {
  const result = yield* Effect.promise(() => somePromise())
})

4. Address Type Differences

// viem - string addresses
const addr: `0x${string}` = '0x742d35Cc6634C0532925a3b844Bc9e7595f...'

// voltaire-effect - Uint8Array addresses
import * as Address from 'voltaire-effect/primitives/Address'
import * as S from 'effect/Schema'

const addr = S.decodeSync(Address.Hex)('0x742d35Cc6634C0532925a3b844Bc9e7595f...')
// AddressType (branded Uint8Array)

// Convert back to hex string
const hexString = Address.toHex(addr)

5. Layer Composition Matters

// Compose layers with their dependencies properly
const CryptoLayer = Layer.mergeAll(Secp256k1Live, KeccakLive)
const TransportLayer = HttpTransport(rpcUrl)
const ProviderLayer = Provider.pipe(Layer.provide(TransportLayer))

const DepsLayer = Layer.mergeAll(LocalAccount(privateKey), ProviderLayer, CryptoLayer)
const MainLayer = Signer.Live.pipe(Layer.provide(DepsLayer))

Quick Reference

viem/ethersvoltaire-effect
await fn()yield* fn()
try/catchEffect.catchTag
createPublicClientProviderService + layers
createWalletClientSignerService + layers
privateKeyToAccountLocalAccount(key)
getAddress(str)S.decodeSync(Address.Hex)(str)
parseEther('1')1000000000000000000n

Incremental Migration with tryPromise / trySync

You can keep viem in place and wrap it so it composes with Effect. Use Effect.tryPromise for existing async APIs, and use Effect.try (sync) as a trySync helper for local parsing/validation.
import { Effect } from 'effect'
import { createPublicClient, http } from 'viem'
import * as Address from 'voltaire-effect/primitives/Address'
import * as S from 'effect/Schema'

const client = createPublicClient({ transport: http(rpcUrl) })
const trySync = Effect.try

export const getBalance = (address: string) =>
  Effect.gen(function* () {
    const addr = yield* trySync({
      try: () => S.decodeSync(Address.Hex)(address),
      catch: (error) => new Error(`Invalid address: ${String(error)}`)
    })

    const balance = yield* Effect.tryPromise({
      try: () => client.getBalance({ address }),
      catch: (error) => new Error(`RPC failed: ${String(error)}`)
    })

    return { addr, balance }
  })
Once you are ready, replace the viem call with the getBalance free function and provide a Provider layer instead of the tryPromise wrapper.

See Also