Skip to content

fix(generate): convert opt opt record fields through their three states - #198

Open
marc0olo wants to merge 3 commits into
fix/optional-presence-encodingfrom
fix/nested-option-record-fields
Open

marc0olo wants to merge 3 commits into
fix/optional-presence-encodingfrom
fix/nested-option-record-fields

Conversation

@marc0olo

@marc0olo marc0olo commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #197.

A record field of type opt opt T is declared field?: T | null, but its values were converted as something else. This PR keeps the declared type and fixes the conversions, and documents the rule in structure.md. That is a design choice as much as a fix, so the alternatives are laid out below.

Behaviour

record { f : opt opt Cfg } before after
declared type f?: Cfg | null f?: Cfg | null, unchanged
decoded from [] { __kind__: "None" } undefined
decoded from [[]] { __kind__: "Some", value: null } null
decoded from [[cfg]] { __kind__: "Some", value: cfg } cfg
{ f: undefined } or omitted, encoded [] []
{ f: null } encoded []: the state [[]] could not be sent [[]]
{ f: cfg } encoded [[cfg]] [[cfg]]

This changes what goes on the wire for null, and what a decoded field holds. The same applies when the inner option is reached through a name (type Inner = opt text; record { f : opt Inner }). A deeper option follows the same rule: opt opt opt text is declared f?: Some<string | null> | None, and decoding [[[]]] gave Some(Some(null)), one level too many; it now gives Some(null). Fields with a single opt, and opt opt in any other position, are unchanged.

Where it matters: Internet Identity's config record uses opt opt for set/clear fields (analytics_config, dummy_auth, dnssec_config, doh_config, mcp_official_url): absent keeps the stored value, opt null clears it, a value sets it. Through the wrapper, a caller could not clear such a field, and a caller reading one got an object where the type promises T | null.

How this was found

Not from a user report: probing the generator with an opt opt record field, and typechecking the wrappers of real interfaces with @ts-nocheck removed (Internet Identity fails with TS2322 there). No caller going through a bindgen wrapper for these fields turned up. The shape is common in real interfaces, though: besides Internet Identity's config, the asset canister's SetAssetPropertiesArguments has four opt opt fields (max_age, headers, allow_raw_access, is_aliased) with the same keep/clear/set meaning. A JS client clearing max_age through the wrapper would send "keep" instead, without an error.

Where the rule comes from

The representation was designed in dfinity/candid#601 and #672 (neither merged) and ported here in 62fcb7b:

  • opt T is T | null.
  • A nested option is Some<…> | None, since T | null | null would merge two states.
  • A record field opt X is optional, f?: <X>, with undefined as the absent state.

structure.md documents all three, but its record example has a single opt, and neither candid's tests (optional.did nests only standalone options) nor this repo's covered an opt opt field. What such a field should be was never written down, and three sources disagree:

[] [[]] [[v]]
declared type, the field rule applied to X = opt T undefined null v
candid#672's description (metadata?: Option<Record>) undefined None Some(v)
runtime on main None Some(null) Some(v)

The runtime matches neither design: under both, an absent field is undefined. It is the standalone nested conversion (Some<T | null> | None) applied to the whole field, whose result record_opt_to_undefined passes through because it only maps null.

The principle behind the mapping

Every option mapping in bindgen follows one rule: use a native JavaScript empty value where one is available, and the tagged Some/None only where JavaScript runs out of them.

Candid empty states TypeScript
opt T 1 T | null
record field opt T 1 f?: T (omitted)
record field opt opt T 2 f?: T | null (omitted, null)
opt opt T on its own, or a third level more than JavaScript has Some<…> | None

Variants follow the same rule: a payload has no native form, so it is tagged with __kind__. The declared type of an opt opt field already follows it; the conversions did not.

Options

A. Keep the declared type, fix the conversions (this PR).

  • It follows the principle above: both inner states have a native value, so nothing is tagged. It is also the field rule applied without exception: f?: <X> with X = opt T.
  • The declared types don't change, so code that typechecks keeps typechecking. Only the values change, to the ones the types already promise.
  • It uses both empty values JavaScript has: an omitted field for the outer level, null for the inner one.
  • Against: a field's null now means "present but empty", unlike a plain opt T field where null is not a declared value. Anyone who worked around the Some/None objects with casts gets different values.

B. Declare f?: Some<T> | None (candid#672's example).

  • It spells the inner level explicitly, so null is not overloaded.
  • Against: it tags a state JavaScript can represent natively, which goes against the principle. It also changes the declared type of every opt opt field, a breaking change. It is not the field rule applied, so it needs a special case in both the declarations and the conversions. The runtime still needs fixing, because an absent field decodes to None today, not undefined.

C. Declare what the runtime does, f?: Some<T | null> | None.

  • It matches today's values exactly.
  • Against: the absent field is encoded two ways (omitted and None), so it isn't a clean mapping of three states. It still changes the declared type, and it is the standalone rule applied where the field rule is documented.

D. Reject opt opt record fields. Not viable: Internet Identity uses them.

A is the smallest change for callers and the only one that needs no special case in the rule, which is why this PR takes it. B is the alternative if reviewers prefer an explicit inner level over reusing null.

Why the conversions were wrong

The decoder converted the field with the standalone nested-option conversion, then with record_opt_to_undefined. The encoder tested presence as for a single opt, so null read as absent. For a field whose type is itself an option, the outer level is now length === 0 ? undefined : … when decoding and !== undefined when encoding, and the inner level goes through the standalone conversion.

Testing

tests/conversion.test.ts sends and receives all three states for a field that needs no conversion (opt opt text), one that does (opt opt Cfg), one reached through a name, and a deeper opt opt opt text. All four fail on #197 alone. The generated wrapper for the fixture typechecks under strict against @icp-sdk/core 6.1.0 with @ts-nocheck removed. No existing snapshot moves.

🤖 Generated with Claude Code

A record field of type `opt opt T` is declared `field?: T | null`:
undefined is absent, null is present but empty, and a value is set.
Decoding returned a Some/None object instead, and encoding sent null as
absent, so the empty state could not be sent. The same applies when the
inner option is reached through a name.
The record field rule was only documented for a single option. Show the
three states of an opt opt field, and that a deeper option continues
with Some/None; the conversion test now covers the deeper case.
@marc0olo
marc0olo force-pushed the fix/nested-option-record-fields branch from 7d095b7 to ec2e22b Compare September 28, 2026 15:44
@marc0olo
marc0olo requested a balanced review from Copilot September 28, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The implementation is coherent and tested, but intentionally changes wire semantics through a public type-mapping design decision.

Review effort: Balanced
Findings: None

What changed in this PR

Fixes opt opt record-field conversion so runtime values match generated TypeScript types.

Changes:

  • Distinguishes undefined, null, and populated values during encoding and decoding.
  • Adds nested-option conversion tests and documentation.
File Description
tests/​conversion.test.ts Tests nested optional field states.
tests/​assets/​nested_option_fields.did Adds the nested-option fixture.
src/​core/​generate/​rs/​src/​bindings/​typescript_native/​conversion_functions_generator.rs Corrects nested optional field conversions.
docs/​src/​content/​docs/​structure.md Documents the three-state representation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants