5月27日 20:04

Hardhat 如何支持 TypeScript 和类型安全?

Hardhat 对 TypeScript 的支持不是"能用"级别,而是"原生级"。初始化项目时直接选 TypeScript 模板,配置文件、部署脚本、测试文件全部 .ts,编译合约后还能自动生成类型定义——你调用合约方法时,编辑器会告诉你参数类型对不对、返回值是什么。

智能合约一旦部署上链就很难改,类型检查能在编译阶段把低级错误拦住,这个价值不需要多解释。下面从项目搭建到实际开发,把 Hardhat + TypeScript 的完整链路走一遍。

项目初始化:选 TypeScript 模板

bash
mkdir my-project && cd my-project npm init -y npx hardhat init # 选择 "Create a TypeScript project"

Hardhat 会自动生成 hardhat.config.tstsconfig.json,并安装必要的 TypeScript 依赖:

bash
npm install --save-dev ts-node typescript @types/node @types/mocha

如果你用的是 @nomicfoundation/hardhat-toolbox(推荐),这些依赖已经包含在内,不用手动装。

一个默认生成的 hardhat.config.ts 长这样:

typescript
import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; const config: HardhatUserConfig = { solidity: "0.8.24", }; export default config;

HardhatUserConfig 这个类型会帮你检查配置项有没有写错——比如把 solidity 拼成 solididy,TypeScript 直接报红。

Hardhat Toolbox:一个包装搞定类型安全

@nomicfoundation/hardhat-toolbox 是 Hardhat 官方推荐的插件合集,包含了 TypeChain、Ethers.js、Chai 匹配器等,装一个包就把类型安全的环境搭好:

bash
npm install --save-dev @nomicfoundation/hardhat-toolbox

hardhat.config.ts 中引入后,执行编译:

bash
npx hardhat compile

你会看到类似输出:

shell
Compiled 1 Solidity file successfully Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v6 Successfully generated 3 typings!

这就是 TypeChain 在工作——它读取合约编译产出的 ABI,自动生成 TypeScript 类型定义文件,放在 typechain-types/ 目录下。

合约交互:从"盲调"到"类型安全"

假设你有一个简单的 Solidity 合约:

solidity
// contracts/Lock.sol // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; contract Lock { uint public unlockTime; address public owner; constructor(uint256 _unlockTime) payable { require(block.timestamp < _unlockTime, "Unlock time should be in the future"); unlockTime = _unlockTime; owner = msg.sender; } }

没有 TypeChain 时

你用 JavaScript 或裸 TypeScript 调用合约方法,没有任何类型提示:

typescript
// 没有类型安全——参数类型、返回值全靠猜 const lock = await ethers.getContractAt("Lock", address); const time = await lock.unlockTime(); // 返回什么类型?不知道

有 TypeChain 后

typescript
import { Lock } from "../typechain-types"; const LockFactory = await ethers.getContractFactory("Lock"); const lock = await LockFactory.deploy(futureTimestamp) as Lock; // 编辑器自动补全,参数类型和返回值都有提示 const time: bigint = await lock.unlockTime(); const owner: string = await lock.owner();

区别很明显:unlockTime() 返回 bigint 而不是 anyowner() 返回 string——如果后续代码把 owner 当数字用,编译阶段就能发现。

测试中的类型安全

Hardhat 的 TypeScript 项目用 Mocha + Chai + Ethers.js 做测试,配合 TypeChain 生成的类型,测试代码也能享受完整的类型检查:

typescript
import { expect } from "chai"; import { ethers } from "hardhat"; import { Lock } from "../typechain-types"; import { time } from "@nomicfoundation/hardhat-network-helpers"; describe("Lock", function () { let lock: Lock; beforeEach(async function () { const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS; const LockFactory = await ethers.getContractFactory("Lock"); lock = await LockFactory.deploy(unlockTime, { value: ethers.parseEther("1") }); }); it("should set the right unlockTime", async function () { const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; const expectedTime = BigInt(await time.latest()) + BigInt(ONE_YEAR_IN_SECS); expect(await lock.unlockTime()).to.be.closeTo(expectedTime, 2n); }); });

注意 lock 变量的类型是 Lock,不是 any——你在测试里调一个不存在的方法,TypeScript 会直接报错,不用等运行时才发现拼写错误。

跑测试时加 --typecheck 可以在执行前做一轮完整类型检查:

bash
npx hardhat test --typecheck

建议在 CI 或 pre-commit hook 里加上这个标志,确保类型问题不会溜进代码库。

配置文件的类型安全

hardhat.config.ts 本身就是类型安全的大本营。HardhatUserConfig 类型会约束你写正确的配置结构:

typescript
import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; const config: HardhatUserConfig = { solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200, }, }, }, networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL || "", accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], }, }, }; export default config;

如果你把 optimizer.enabled 写成 "yes" 而不是 true,TypeScript 立刻报错。这种在配置层面的类型保护,避免了"部署到测试网怎么都不对,最后发现是配置拼错"的尴尬。

环境变量的类型定义

在项目根目录创建 hardhat.config.d.ts,给环境变量加类型:

typescript
declare namespace NodeJS { interface ProcessEnv { SEPOLIA_RPC_URL: string; PRIVATE_KEY: string; ETHERSCAN_API_KEY: string; } }

这样 process.env.SEPOLIA_RPC_URL 在编辑器里就不会被推断为 string | undefined,省去到处写 ! 非空断言。

部署脚本的类型安全

部署脚本是类型安全最容易出现缺口的地方。正确的做法是给部署脚本加上类型:

typescript
// scripts/deploy.ts import { ethers } from "hardhat"; import { Lock } from "../typechain-types"; async function main() { const unlockTime = Math.floor(Date.now() / 1000) + 60 * 60; // 1 小时后 const LockFactory = await ethers.getContractFactory("Lock"); const lock: Lock = await LockFactory.deploy(unlockTime, { value: ethers.parseEther("0.001"), }); await lock.waitForDeployment(); console.log("Lock deployed to:", await lock.getAddress()); } main().catch((error) => { console.error(error); process.exitCode = 1; });

waitForDeployment() 是 ethers v6 的写法——v5 用的是 deployed(),已经在 v6 中被移除。如果你在网上抄到 v5 的代码直接用,TypeScript 会直接报方法不存在,这恰好是类型安全帮你挡住的一个常见坑。

常见踩坑和解决方案

1. 全局变量 vs 显式导入

JavaScript 项目里 Hardhat 会把 ethers 等对象注入全局作用域,但 TypeScript 项目必须显式导入:

typescript
// ✅ 正确 import { ethers } from "hardhat"; // ❌ TypeScript 中不存在全局 ethers

如果你从 JS 项目迁移过来,记得把所有全局引用改成 import。

2. ethers v5 和 v6 的写法差异

2024 年后 hardhat-toolbox 默认使用 ethers v6,关键差异:

操作ethers v5ethers v6
等待部署完成await contract.deployed()await contract.waitForDeployment()
获取合约地址contract.addressawait contract.getAddress()
解析 ETH 单位ethers.utils.parseEther("1")ethers.parseEther("1")
BigInt 转换value.toNumber()Number(value) 或直接用 bigint

如果项目里混用了 v5 和 v6 的写法,TypeScript 的类型检查会帮你发现不兼容的调用——前提是你装的是 v6 版本的类型定义。

3. TypeChain 生成文件要不要提交到 Git

建议把 typechain-types/ 加入 .gitignore,让它在每次编译时重新生成。这样合约改动后类型定义总是最新的,不会出现代码和类型不同步的问题。

4. 类型检查只在显式请求时执行

Hardhat 默认运行任务时不做类型检查(为了速度)。你可以在 hardhat.config.ts 中设置 typechain.target 确保生成正确版本,但类型检查需要手动触发:

bash
# 单独跑类型检查 npx hardhat compile && npx tsc --noEmit # 或在测试时加上 --typecheck npx hardhat test --typecheck

在 CI 流水线里加一个 tsc --noEmit 步骤,能确保每次提交都不会引入类型错误。

5. Hardhat Runtime Environment 的类型扩展

如果你装了第三方插件,hre 上可能缺少类型声明。可以通过模块扩展补上:

typescript
// hardhat.config.ts import "hardhat/types/runtime"; declare module "hardhat/types/runtime" { interface HardhatRuntimeEnvironment { myCustomPlugin: { doSomething: () => Promise<void>; }; } }

但要注意,随意扩展类型容易和其他插件冲突,只在确实需要时才这么做。

TypeScript vs JavaScript:值不值得切?

简单对比一下在 Hardhat 项目中的实际体感:

方面JavaScriptTypeScript
合约调用返回 any,参数类型靠记忆自动补全 + 类型检查
配置错误运行时才报错编译时直接标红
重构合约全局搜索替换,容易遗漏改一处,引用处全部报错
团队协作看注释或源码才知道参数含义类型和注释一体化
学习成本需要理解类型系统,但 Hardhat 模板已配好

对于新项目,没有理由不用 TypeScript——Hardhat 的模板已经帮你把基础设施搭好了,额外成本几乎为零。老项目迁移需要一点工作量,主要是加 import 和类型声明,但迁移完成后维护体验明显提升。

总结一句话:Hardhat 的 TypeScript 支持不是锦上添花,是标配。从项目初始化到合约交互、测试、部署,全链路都有类型保护。唯一需要留意的是 ethers v5/v6 的 API 差异,以及显式导入 vs 全局变量的区别——搞清楚这两点,剩下的跟着模板走就行。

标签:Hardhat