All posts
8 min read

TypeChain: The Bridge Between Solidity Smart Contracts and TypeScript

How I used TypeChain at io.finnet to turn Solidity smart contracts into fully typed TypeScript packages, and why bridging these two worlds made everything click.

TypeScriptSolidityWeb3TypeChainSmart Contracts

If you've ever worked in Web3 as a frontend or fullstack engineer, you've probably hit that moment where you stare at a smart contract and think: "Cool... but how do I actually call this thing from my app without guessing method names and hoping for the best?"

That was me at io.finnet.

The Problem

At io.finnet, we build institutional digital asset custody infrastructure. MPC wallets, secure vault management, the whole nine yards. Part of that stack involves Solidity smart contracts that handle on-chain asset transfers. My job as a fullstack engineer was to make those contracts accessible to our frontend products and internal tools.

Here's the thing about Solidity and TypeScript: they don't naturally speak the same language. Solidity lives on the blockchain. TypeScript lives in your IDE. And between them? A JSON ABI file that looks like someone serialised a phone book.

If you've ever interacted with a smart contract using raw ethers.js, you know the pain. You're writing things like:

const result = await contract.someMethodThatMightExist('0xabc...', 100);
// No autocomplete. No type checking.
// Did I pass the right args? Who knows. Ship it and pray.

No autocomplete. No type safety. Just vibes and hope. You won't know you've called a non-existent method until runtime blows up in your face. That's not how I like to build things.

I needed a way to call smart contract methods the same way I'd call any typed API. Full autocomplete, type checking, and the confidence that comes with knowing your compiler has your back.

The Solution: TypeChain

Enter TypeChain.

TypeChain is a code generator. You feed it your Solidity ABI files, tell it which blockchain library you're using (ethers.js, in our case), and it spits out fully typed TypeScript bindings. Every method. Every event. Every parameter. All typed. All autocomplete-able.

The concept is dead simple:

Solidity Contract → Compile → ABI (JSON) → TypeChain → TypeScript Types + Factory Classes

Instead of guessing method names and parameter types, you get this:

import { VaultFacet } from '@iofinnet/contracts';

// Full autocomplete. Full type safety. Full confidence.
const tx = await vaultFacet.transferAsset(
  recipientAddress, // TypeChain knows this is a string (address)
  tokenAmount, // TypeChain knows this is a BigNumberish
);

Your IDE lights up. Red squiggles if you pass wrong types. Autocomplete for every method on the contract. It felt like calling a REST API, but on-chain. That's the developer experience I was after.

The Diamond Pattern: Why It Mattered

Now, here's where the architecture gets interesting.

I was working closely with our Solidity engineer, Axel, who had implemented the Diamond pattern (EIP-2535) across our smart contracts. If you're not familiar, the Diamond pattern is an elegant solution to one of Solidity's biggest headaches: upgradeability without losing state.

Smart contracts on Ethereum are immutable by default. Once deployed, you can't change them. If you find a bug or need to add a feature, tough luck. Your state is baked into that contract address forever. Traditional proxy patterns let you swap out logic, but they come with limitations (single implementation contract, storage collision risks, the 24KB contract size limit).

The Diamond pattern takes a different approach. Instead of one proxy pointing to one implementation, a Diamond contract acts as a router that delegates calls to multiple implementation contracts called facets. Think of it like a microservices architecture, but on-chain:

Diamond (Router)
├── VaultFacet       → vault management logic
├── TransferFacet    → asset transfer logic
├── PolicyFacet      → governance and policy logic
└── AdminFacet       → admin operations

Each facet handles a slice of functionality. Need to upgrade the transfer logic? Deploy a new TransferFacet, register it with the Diamond via diamondCut, and your state stays exactly where it is. No migration. No data loss. The Diamond's storage persists across all facets because they all execute in the Diamond's context via delegatecall.

Axel's implementation was fantastic. Clean separation of concerns, modular facets, each one independently upgradeable. But from a TypeScript perspective, it meant I wasn't dealing with one contract. I was dealing with many facets, each with its own ABI, all sharing the same Diamond address.

This is where TypeChain really earned its keep.

Wiring It All Together

Here's what the workflow looked like in practice.

Step 1: Axel compiles the Solidity contracts.

Each facet produces its own ABI artifact after compilation via Hardhat. So you end up with a folder full of JSON files like VaultFacet.json, TransferFacet.json, PolicyFacet.json, and so on.

Step 2: TypeChain generates the TypeScript bindings.

With a single command in our build pipeline:

typechain --target ethers-v5 --out-dir src/generated './artifacts/**/*.json'

TypeChain scans every ABI artifact and generates a corresponding .ts file with:

  • A typed contract interface with every method, every event, every parameter fully typed
  • A factory class for connecting to a deployed contract at a given address

For a Diamond setup, this means each facet gets its own typed interface. You connect to the same Diamond address but cast it through different facet types depending on what you need to do:

import { VaultFacet__factory, TransferFacet__factory } from '@iofinnet/contracts';
import { ethers } from 'ethers';

const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
const signer = provider.getSigner();

// Same Diamond address, different typed interfaces
const vaultFacet = VaultFacet__factory.connect(DIAMOND_ADDRESS, signer);
const transferFacet = TransferFacet__factory.connect(DIAMOND_ADDRESS, signer);

// Now each variable only exposes the methods for that facet
await vaultFacet.createVault(vaultName, threshold);
await transferFacet.transferAsset(recipient, amount);

This is the moment it clicked for me. The Diamond pattern gave us modular, upgradeable contracts. TypeChain gave us modular, typed interfaces that mirrored that exact architecture. The mental model lined up perfectly.

Step 3: Package it up and export.

We bundled the generated types and factory classes into an internal npm package, @iofinnet/contracts. Other products in the ecosystem — the web dashboard, the mobile signing app, internal tooling — just installed the package and got full type safety against the latest contract interfaces.

npm install @iofinnet/contracts

That's it. Any team member working on any product could import the contract types, get autocomplete, and interact with the blockchain with the same confidence they'd have calling a REST endpoint. No guessing. No runtime surprises.

Simple.

The Frontend: Where It All Came Together

When I implemented the frontend components to actually use these typed contracts, it worked like a charm.

React hooks that call contract methods? Typed. Transaction builders? Typed. Event listeners filtering for specific on-chain events? Typed. Every piece of the chain — from Axel's Solidity facets to the TypeChain bindings to the React components rendering in the browser — spoke the same typed language.

There's something deeply satisfying about building a <TransferAsset /> component, importing the typed contract, and having your IDE tell you exactly what parameters the transferAsset method expects, what it returns, and what events it emits. No docs-diving. No ABI-squinting. Just code.

The bugs that didn't happen were the real win. No more typos in method names discovered at runtime. No more passing a number where the contract expected a BigNumberish. No more "works on my machine" moments caused by stale ABI files. TypeChain caught all of that at compile time.

There Is Beauty in Simplicity

Looking back, the whole setup was elegant in its simplicity:

  1. Axel writes and deploys modular Diamond facets in Solidity
  2. TypeChain generates typed TypeScript bindings from the compiled ABIs
  3. We export those bindings as an npm package
  4. Every product in the ecosystem imports the package and gets full type safety

Four steps. Two engineers. One typed bridge between on-chain and off-chain.

No complex middleware. No hand-written type definitions that drift out of sync. No bespoke SDK that someone has to maintain. Just a code generator that reads the source of truth (the ABI) and produces types that the TypeScript compiler can enforce.

It was a great experience and a reminder that the best solutions are often the simplest ones. You don't always need to build something clever. Sometimes you just need to find the right tool that connects two worlds cleanly, and then get out of the way.

There is beauty in simplicity.

A Note From 2026

I implemented all of this roughly two years ago. Since then, the Web3 TypeScript ecosystem has evolved significantly. Viem, Wagmi, and ABIType have emerged as the modern approach to this same problem, and they solve it differently. Instead of a code generation step, ABIType parses ABI definitions directly within the TypeScript type system using as const assertions. No build step. No generated files. Just pass in your ABI and get full type inference on the spot.

TypeChain's creator, Krzysztof Kaczor, has himself moved on and recommends this newer stack. That's the nature of open source. Tools evolve, better patterns emerge, and the community pushes forward.

But here's the thing: TypeChain solved the problem when I needed it solved. It was stable, well-documented, and worked beautifully with our Hardhat + ethers.js + Diamond pattern setup. If I were starting fresh today, would I reach for Viem and ABIType? Probably. But I'd be standing on the shoulders of what TypeChain proved was possible. That Solidity and TypeScript should speak the same language, and that the bridge between them doesn't need to be complicated.

The tools change. The principle doesn't.