Exploring the intersection between TypeScript, Ethereum, GraphQL, and other exciting technologies.

Inferring TypeScript types directly from an Ethereum JSON ABI

12th Apr 2022

Getting typed methods into your Web3 client library (Ethers.js or Web3.js) can be hard and requires a build step.

As TypeScript is getting more and more powerful, I was wondering: “maybe it’s possible to infer types directly from the JSON ABI?”.

People have built crazy things with Typescript before. This should be possible.

First, let’s figure out the structure of the Ethereum ABI.

Focusing on a single method, balanceOf, on this ERC20 ABI, the ABI looks like this:

[
	{
    "constant": true,
    "inputs": [
      {
        "name": "_owner",
        "type": "address"
      }
    ],
    "name": "balanceOf",
    "outputs": [
      {
        "name": "balance",
        "type": "uint256"
      }
    ],
    "payable": false,
    "stateMutability": "view",
    "type": "function"
  },
	... // the other methods
]

The important things here are the name, inputs and ouputs.

Our goal is to get a Typescript contract type that looks something like this:

type Address = string;
type Uint256 = BigNumber;

type ContractType = {
  balanceOf: (_owner: Address) => Promise<Uint256>;
	// ... other methods
};

Step one: Getting method names from JSON to the ContractType

First, let’s focus on getting our method names from the ABI into a typed object we call ContractType.

const abi = [{name: "balanceOf"}];

type ContractType = {
  balanceOf: () => void
};

As a naive first try, I’ll use [abi[0].name] instead of balanceOf as a key.

type ContractType = {
  [abi[0].name]: () => void
};

// TypeError: A computed property name in a type literal must refer to an expression whose type is a literal type or a 'unique symbol' type.

TypeScript can’t infer types from runtime variables (in this case abi).

One alternative would be to use typeof abi, but then we only get access to string and not "balanceOf"... 🤔

typeof abi

// type Abi = {
//    name: string;
// }[]

It seems like the core of the problem is that TypeScript infers arrays to be general strings instead of the specific keys.

// ✅ this we can work with
"balanceOf" // : "balanceOf"

// ❌ this only gives us a "string" type :(
["balanceOf"] // : string[]

Maybe you need some more specific types like Enums to fix this. Hm...

So I went back to thinking and did some research. Found this article that investigates this problem directly.

const animals = ['cat', 'dog', 'mouse']

function getAnimal(name: string) {
  return animals.find(a => a === name)
}

// getAnimal('cat')      // OK
// getAnimal('dog')      // OK
// getAnimal('mouse')    // OK
// getAnimal('elephant') // ?
Should I be able to ask for ‘elephant’?

He goes on to actually show a way to get more specific types by using as const:

const animals = ['cat', 'dog', 'mouse'] as const

// type Animal = 'cat' | 'dog' | 'mouse'

Wihu 🥳 This means there is still hope. Plugging this into our abi we can successfully infer a MethodNames type with specific method names. This means we have something to work with!

const abi = [
  { name: "balanceOf" },
  { name: "otherMethod" }
] as const;

type AbiItem = typeof abi[number]
type MethodNames = AbiItem["name"];
// type MethodNames = 'balanceOf' | 'otherMethod'

Using this knowledge, we can create a TypedContract where we iterate typeof abi and extract all methodNames. Even having only this functionality we can already make a semi-typed ethers.js alternative, where methods are autocompleted!

typeof MethodName<T extends AbiItem> = AbiItem["name"]

type TypedContract = ethers.Contract & {
  [item in AbiItem as item["name"]: () => void;
};

const erc20 = new ethers.Contract(address, abi, provider) as TypedContract;

erc20.b // autoCompletes to .balanceOf()

Step two: Type the method’s output

We already found a way to add the methods to the Contract, but now we want to infer the output from the JSON ABI.

// current:
typeof erc20.balanceOf // balanceOf: () => void

// our goal:
typeof erc20.balanceOf // balanceOf: () => Promise<BigNumber>

In the ABI the output is defined like this:

{
  name: "balanceOf",
  outputs: [
    {
      name: "balance",
      type: "uint256"
    }
  ]
},

There are occations where multiple outputs are returned from a function, but usually it’s just one. I will simplify and assume its always outputs.length === 1 here.

Using a similar method as in step one, we can find the types by digging deep enough.

const abi = [
  {
    name: "balanceOf",
    outputs: [
      {
        name: "balance",
        type: "uint256"
      }
    ]
  },
  {
    name: "otherMethod",
    outputs: [
      {
        name: "",
        type: "string"
      }
    ]
  }
] as const;

typeof AbiItem["outputs"][0]["type"];
// 'uint256' | 'string'

In order to map types like "uint256" to BigNumber, we can create a separate type map with all the bindings.

type SolidityToEthers = {
  uint256: BigNumber;
  string: string;
};

const var1: SolidityToEthers["uint256"]; // Type is BigNumber
const var2: SolidityToEthers["string"]; // Type is string

And making a specific Output generic type, and adding Promise, we end up with this code.

type Output<T extends AbiItem> = SolidityToEthers[T["outputs"][0]["type"]]

type TypedContract = ethers.Contract &
  {
    [item in AbiItem as MethodName<item>]: () => Promise<Output<item>>
  };

typeof erc20 // Type is {
//   balanceOf: () => Promise<BigNumber>
//   otherMethod: () => Promise<string>
// }

Great! Now we just have the input arguments left:

Step three: Type the method’s input arguments

The final typed methods we are looking for will look like this:

// one argument:
typeof erc20.balanceOf // balanceOf: (_owner: string) => Promise<BigNumber>

// multiple arguments
typeof erc20.transfer // transfer: (_to: string, _value: BigNumber) => Promise<boolean>

Following the theme of how we solved the previous steps, the key is to find a type generic. Input needs to be an array to work with multiple input arguments.


We can use a tuple to type rest parameters.

// we have this structure
[
	{type: "address", name: "_to"},
	{type: "uint256", name: "_value"}
]

// we need this tuple
type Params = [string, BigNumber]

const fn = (...args: Params) => null 
typeof fn // (args_0: string, args_1: BigNumber) => null

This is a good solution, but gives us generated parameter names (args_0, args_1). We could use a tuple like this: [_to: string, _value: BigNumber], but sadly there is no way to dynamically generate that tuple with mapped types. See my SO question and a Github Issue. Let’s hope this will be implemented in an upcoming TypeScript version!

We can use mapped types to generate a Tuple to use.

type InputToTuple<T extends AbiItem["inputs"]> = { [P in keyof T]: SolidityToEthers[T[P]["type"]] };

Now we can insert InputToTuple into our TypedContract.

type TypedContract = ethers.Contract &
  {
    [item in AbiItem as MethodName<item>]: (...args: InputToTuple<item["inputs"]>) => Promise<Output<item>>
  };

type M = typeof erc20.transfer() // transfer: (args_0: string, args_1: BigNumber) => Promise<boolean>

Wihu! Seems like we did it, kind of?

Final results

Define the abi as const to get the necessary type narrowing. (example ERC20 ABI)

const abi = [
	...
] as const

Then use this TypedContract type.

type SolidityToEthers = {
  uint256: BigNumber;
  string: string;
  bool: boolean;
  address: string;
}

type AbiItem = typeof erc20Abi[number]

type MethodName<T extends AbiItem> = T["name"]
type Output<T extends AbiItem> = SolidityToEthers[T["outputs"][0]["type"]]
type InputToTuple<T extends AbiItem["inputs"]> = { [P in keyof T]: SolidityToEthers[T[P]["type"]] };

type TypedContract = ethers.Contract &
  {
    [item in AbiItem as MethodName<item>]: (...args: InputToTuple<item["inputs"]>) => Promise<Output<item>>
  };

And you’ll have a typed Contract instance!

const erc20 = new ethers.Contract(address, erc20Abi, provider) as TypedContract;

erc20.transfer() // Type is (args_0: string, args_1: BigNumber) => Promise<boolean>

See full example in this CodeSandbox.

Conclusion

Using TypeScript we were able to infer 90% of the types for typing a contract in Ethers. The only thing missing is the named parameters for inputs.

There are many usecases where you want to safely interact with smart contracts without generating contract types as a build step. This TypeScript first method might be the answer!


Tools