This example shows how to automatically generate Rust types from a Candid interface definition (.did file), eliminating the need to manually copy and maintain type definitions from external canisters.
The example deploys a canister that calls the NNS Governance canister using types generated directly from its live Candid interface. The local network is configured with nns: true so that NNS canisters are available at their mainnet IDs during local development and testing.
candid/nns_governance.did ← Candid interface fetched from the live canister
↓ (build.rs)
$OUT_DIR/nns_governance.rs ← Rust types generated at build time
↓ (include! in declarations/mod.rs)
declarations::nns_governance::* ← Types available in your canister code
The build script uses ic-cdk-bindgen to generate Rust types from the .did file before the main compilation starts:
Config::new("nns_governance", "candid/nns_governance.did")
.static_callee(Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap())
.set_type_selector_config("candid/nns_governance.toml")
.generate();Config::new(name, candid_path)—namebecomes the output filename (nns_governance.rs).static_callee(principal)— embeds the target canister ID as a constant in the generated code. Use.dynamic_callee("ENV_VAR_NAME")when the canister ID varies across environments..set_type_selector_config(path)— TOML file controlling which traits are derived on generated types.generate()— writes to$OUT_DIR/nns_governance.rs(outside ofsrc/, not committed to git)
The cargo:rerun-if-changed directives ensure the build script only re-runs when the .did or .toml files change, keeping incremental rebuilds fast.
candid/nns_governance.toml controls how Candid types are mapped to Rust:
[rust]
visibility = "pub"
attributes = "#[derive(CandidType, Deserialize, Debug, Clone, serde::Serialize)]"By default, ic-cdk-bindgen derives CandidType and Deserialize on all types. The attributes field adds Debug, Clone, and serde::Serialize — necessary here because the example uses serde_json::to_string_pretty to display the response.
See the Type Selector specification for the full configuration syntax.
The generated file lives in Cargo's build output directory and is brought into scope via include!:
// backend/src/declarations/mod.rs
pub mod nns_governance {
include!(concat!(env!("OUT_DIR"), "/nns_governance.rs"));
}The include! macro pastes the file contents at compile time, exactly as if you had written the code directly. This is the standard pattern for ic-cdk-bindgen 0.2.0+. The types are then available in your canister:
use declarations::nns_governance::{ListNeurons, ListNeuronsResponse};Generated code for a .static_callee canister provides top-level async functions and a CANISTER_ID constant:
// Generated in $OUT_DIR/nns_governance.rs
pub const CANISTER_ID: Principal = Principal::from_slice(&[/* ... */]);
pub async fn list_neurons(arg: &ListNeurons) -> CallResult<ListNeuronsResponse> {
Ok(Call::bounded_wait(CANISTER_ID, "list_neurons").with_arg(arg).await?.candid()?)
}
// ... one function per canister methodCalling a method is a single typed function call — no raw Principal arguments or string method names:
declarations::nns_governance::list_neurons(&request).awaitcandid/nns_governance.did is already checked in and ready to use. To update it from the live canister:
bash scripts/fetch_candid.shThis fetches the candid:service metadata from the NNS Governance canister on the IC mainnet and writes it to candid/nns_governance.did.
After refreshing, rebuild to regenerate the Rust types:
icp build backendIf the Candid interface changed in a way that breaks your code (a removed method, a renamed required field), you will get a compile error — not a runtime failure. Note that Candid-compatible changes like adding an optional field also require updating code that constructs request structs, even though the .did file itself is still valid.
Reproducibility note: Always commit the .did file. The generated Rust ($OUT_DIR/nns_governance.rs) is deterministically derived from it at build time, so committing the .did is sufficient to reproduce any historical build exactly. Regenerating from mainnet at an arbitrary time would produce different types if the canister interface had changed, making it impossible to reproduce historical WASM bytes — which matters for any third-party verification.
Possible enhancement: Run a bot to fetch the latest .did file from mainnet on a regular cadence and open a PR if the project still compiles. This keeps types current with zero manual effort.
| Manual copying | ic-cdk-bindgen |
|
|---|---|---|
| Keeping types current | Track upstream changes manually | Re-run fetch_candid.sh |
| Missing dependent types | Easy to overlook | Full interface generated |
| Interface mismatches | Runtime errors | Build-time compile errors |
| Maintenance | Ongoing manual work | Minimal |
- Node.js v18+
- icp-cli:
npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm - Rust with
wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknown
git clone https://github.com/dfinity/examples
cd examples/rust/candid_type_generation
icp network start -d
icp deploy
bash test.sh
icp network stopThe local network starts with nns: true (configured in icp.yaml), which deploys all NNS canisters at their mainnet canister IDs. This means the inter-canister call to NNS Governance works identically in local development and on the IC mainnet.
For information about security best practices when developing ICP canisters, see https://docs.internetcomputer.org/guides/security/overview