Hardhat 如何支持 TypeScript 和类型安全?
Hardhat 对 TypeScript 的支持不是"能用"级别,而是"原生级"。初始化项目时直接选 TypeScript 模板,配置文件、部署脚本、测试文件全部 .ts,编译合约后还能自动生成类型定义——你调用合约方法时,编辑器会告诉你参数类型对不对、返回值是什么。
智能合约一旦部署上链就很难改,类型检查能在编译阶段把低级错误拦住,这个价值不需要多解释。下面从项目搭建到实际开发,把 Hardhat + TypeScript 的完整链路走一遍。
项目初始化:选 TypeScript 模板
bashmkdir my-project && cd my-project npm init -y npx hardhat init # 选择 "Create a TypeScript project"
Hardhat 会自动生成 hardhat.config.ts、tsconfig.json,并安装必要的 TypeScript 依赖:
bashnpm install --save-dev ts-node typescript @types/node @types/mocha
如果你用的是 @nomicfoundation/hardhat-toolbox(推荐),这些依赖已经包含在内,不用手动装。
一个默认生成的 hardhat.config.ts 长这样:
typescriptimport { 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 匹配器等,装一个包就把类型安全的环境搭好:
bashnpm install --save-dev @nomicfoundation/hardhat-toolbox
在 hardhat.config.ts 中引入后,执行编译:
bashnpx hardhat compile
你会看到类似输出:
shellCompiled 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 后
typescriptimport { 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 而不是 any,owner() 返回 string——如果后续代码把 owner 当数字用,编译阶段就能发现。
测试中的类型安全
Hardhat 的 TypeScript 项目用 Mocha + Chai + Ethers.js 做测试,配合 TypeChain 生成的类型,测试代码也能享受完整的类型检查:
typescriptimport { 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 可以在执行前做一轮完整类型检查:
bashnpx hardhat test --typecheck
建议在 CI 或 pre-commit hook 里加上这个标志,确保类型问题不会溜进代码库。
配置文件的类型安全
hardhat.config.ts 本身就是类型安全的大本营。HardhatUserConfig 类型会约束你写正确的配置结构:
typescriptimport { 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,给环境变量加类型:
typescriptdeclare 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 v5 | ethers v6 |
|---|---|---|
| 等待部署完成 | await contract.deployed() | await contract.waitForDeployment() |
| 获取合约地址 | contract.address | await 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 项目中的实际体感:
| 方面 | JavaScript | TypeScript |
|---|---|---|
| 合约调用 | 返回 any,参数类型靠记忆 | 自动补全 + 类型检查 |
| 配置错误 | 运行时才报错 | 编译时直接标红 |
| 重构合约 | 全局搜索替换,容易遗漏 | 改一处,引用处全部报错 |
| 团队协作 | 看注释或源码才知道参数含义 | 类型和注释一体化 |
| 学习成本 | 低 | 需要理解类型系统,但 Hardhat 模板已配好 |
对于新项目,没有理由不用 TypeScript——Hardhat 的模板已经帮你把基础设施搭好了,额外成本几乎为零。老项目迁移需要一点工作量,主要是加 import 和类型声明,但迁移完成后维护体验明显提升。
总结一句话:Hardhat 的 TypeScript 支持不是锦上添花,是标配。从项目初始化到合约交互、测试、部署,全链路都有类型保护。唯一需要留意的是 ethers v5/v6 的 API 差异,以及显式导入 vs 全局变量的区别——搞清楚这两点,剩下的跟着模板走就行。