From 6e45b9a13e7b289513e2b9b20949e5ee4624d5ba Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 28 Sep 2026 17:05:37 +0200 Subject: [PATCH 1/3] fix(generate): convert opt opt record fields through their three states 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. --- .../conversion_functions_generator.rs | 69 ++++++++++++++++++- tests/assets/nested_option_fields.did | 15 ++++ tests/conversion.test.ts | 49 +++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/assets/nested_option_fields.did diff --git a/src/core/generate/rs/src/bindings/typescript_native/conversion_functions_generator.rs b/src/core/generate/rs/src/bindings/typescript_native/conversion_functions_generator.rs index a34be96..432e843 100644 --- a/src/core/generate/rs/src/bindings/typescript_native/conversion_functions_generator.rs +++ b/src/core/generate/rs/src/bindings/typescript_native/conversion_functions_generator.rs @@ -262,6 +262,25 @@ impl<'a> TypeConverter<'a> { }) } + /// `value !== undefined`: whether an optional record field whose type is itself an option + /// is present. There `null` is a value: the inner option's empty state. + fn is_defined(&self, value: Expr) -> Expr { + Expr::Bin(BinExpr { + span: DUMMY_SP, + op: BinaryOp::NotEqEq, + left: Box::new(value), + right: Box::new(self.create_ident("undefined")), + }) + } + + /// Whether `ty` is an option, directly or through a name. A record field `opt ty` is then + /// declared `field?: `, so `undefined` is its only absent state. + fn is_opt(&self, ty: &Type) -> bool { + self.env + .trace_type(ty) + .is_ok_and(|t| matches!(t.as_ref(), TypeInner::Opt(_))) + } + /// Generate the body of a TypeScript -> Candid conversion function fn generate_to_candid_body(&mut self, ty: &Type, param_name: &str) -> Expr { match ty.as_ref() { @@ -568,10 +587,15 @@ impl<'a> TypeConverter<'a> { let value = match field.ty.as_ref() { TypeInner::Opt(inner) => { // For optional fields, handle undefined/null specially + let present = if self.is_opt(inner) { + self.is_defined(field_access.clone()) + } else { + self.is_present(field_access.clone()) + }; if !self.needs_conversion(inner) { Expr::Cond(CondExpr { span: DUMMY_SP, - test: Box::new(self.is_present(field_access.clone())), + test: Box::new(present.clone()), cons: Box::new(self.create_call( "candid_some", vec![self.create_arg(field_access.clone())], @@ -584,7 +608,7 @@ impl<'a> TypeConverter<'a> { Expr::Cond(CondExpr { span: DUMMY_SP, - test: Box::new(self.is_present(field_access.clone())), + test: Box::new(present), cons: Box::new(self.create_call( "candid_some", vec![self.create_arg(self.create_call( @@ -1336,6 +1360,47 @@ impl<'a> TypeConverter<'a> { // Convert the field value based on its type let value = match field.ty.as_ref() { + TypeInner::Opt(inner) if self.is_opt(inner) => { + // field.length === 0 ? undefined : from_candid_inner(field[0]) + let first = Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(field_access.clone()), + prop: MemberProp::Computed(ComputedPropName { + span: DUMMY_SP, + expr: Box::new(Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: 0.0, + raw: None, + }))), + }), + }); + Expr::Cond(CondExpr { + span: DUMMY_SP, + test: Box::new(Expr::Bin(BinExpr { + span: DUMMY_SP, + op: BinaryOp::EqEqEq, + left: Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(field_access.clone()), + prop: MemberProp::Ident( + Ident::new( + "length".into(), + DUMMY_SP, + SyntaxContext::empty(), + ) + .into(), + ), + })), + right: Box::new(Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: 0.0, + raw: None, + }))), + })), + cons: Box::new(self.create_ident("undefined")), + alt: Box::new(self.convert_from_candid(&first, inner)), + }) + } TypeInner::Opt(_) => { // For optional fields, use a utility function Expr::Call(CallExpr { diff --git a/tests/assets/nested_option_fields.did b/tests/assets/nested_option_fields.did new file mode 100644 index 0000000..f973660 --- /dev/null +++ b/tests/assets/nested_option_fields.did @@ -0,0 +1,15 @@ +// A record field of type `opt opt T` has three states: absent, present but empty, and set. +// Real interfaces use it to keep, clear or set a stored value. +type Cfg = record { url : opt text }; +type Inner = opt text; + +type Fields = record { + text_field : opt opt text; + cfg_field : opt opt Cfg; + named_field : opt Inner; +}; + +service : { + send_fields : (Fields) -> (); + get_fields : () -> (Fields) query; +} diff --git a/tests/conversion.test.ts b/tests/conversion.test.ts index d06cba1..62345ad 100644 --- a/tests/conversion.test.ts +++ b/tests/conversion.test.ts @@ -34,6 +34,13 @@ async function sentBy(serviceName: string, method: string, ...args: unknown[]) { return calls[0]; } +async function receivedBy(serviceName: string, method: string, returned: unknown) { + const actor: RawActor = new Proxy({}, { get: () => async () => returned }); + const loaded = await loadWrapper(serviceName, actor); + cleanup = loaded.cleanup; + return loaded.wrapper[method](); +} + describe('optional values', () => { it('sends present but falsy record fields as present', async () => { const [fields] = await sentBy('optional_presence', 'send_fields', { @@ -68,3 +75,45 @@ describe('optional values', () => { expect(await sentBy('optional_presence', 'send_opt', null)).toEqual([[]]); }); }); + +describe('nested optional record fields', () => { + it('sends absent, present but empty, and set', async () => { + const cfg = { url: 'https://example.org' }; + const [fields] = await sentBy('nested_option_fields', 'send_fields', { + text_field: null, + cfg_field: cfg, + }); + + expect(fields).toEqual({ + text_field: [[]], + cfg_field: [[{ url: ['https://example.org'] }]], + named_field: [], + }); + }); + + it('receives absent, present but empty, and set', async () => { + const received = await receivedBy('nested_option_fields', 'get_fields', { + text_field: [[]], + cfg_field: [[{ url: ['https://example.org'] }]], + named_field: [], + }); + + expect(received).toEqual({ + text_field: null, + cfg_field: { url: 'https://example.org' }, + named_field: undefined, + }); + }); + + it('round-trips a field declared through a named option', async () => { + const [fields] = await sentBy('nested_option_fields', 'send_fields', { named_field: null }); + expect(fields).toMatchObject({ named_field: [[]] }); + + const received = await receivedBy('nested_option_fields', 'get_fields', { + text_field: [], + cfg_field: [], + named_field: [['set']], + }); + expect(received).toMatchObject({ named_field: 'set' }); + }); +}); From ea1736bc3a61f9a7ace65477b9d6cea4b59557b7 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 28 Sep 2026 17:20:51 +0200 Subject: [PATCH 2/3] docs(structure): state how an opt opt record field is represented 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. --- docs/src/content/docs/structure.md | 38 +++++++++++++++++++++++++++ tests/assets/nested_option_fields.did | 1 + tests/conversion.test.ts | 19 ++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/docs/src/content/docs/structure.md b/docs/src/content/docs/structure.md index a6b631e..31d35a4 100644 --- a/docs/src/content/docs/structure.md +++ b/docs/src/content/docs/structure.md @@ -220,6 +220,44 @@ type MyType = { +The field's type is the TypeScript type of what the option holds, so an omitted field (`undefined`) is the absent state. When the option holds another option, the field gets that option's type, and the inner option's absent state is `null`: + +
+ +
Candid
+ +
+ +``` +type MyType = record { + my_field : opt opt text; +} +``` + +
+ +
TypeScript
+ +
+ +```typescript +type MyType = { + my_field?: string | null; +}; +``` + +
+ +
+ +| Candid value | TypeScript value | +| --- | --- | +| `null` (absent) | `undefined`, or the field omitted | +| `opt null` | `null` | +| `opt opt "text"` | `"text"` | + +A deeper option continues with the nested representation: `opt opt opt text` is `my_field?: Some | None`. + #### Variants Candid [variants](https://github.com/dfinity/candid/blob/master/spec/Candid.md#variants) without type parameters are represented as TypeScript enums: diff --git a/tests/assets/nested_option_fields.did b/tests/assets/nested_option_fields.did index f973660..553a593 100644 --- a/tests/assets/nested_option_fields.did +++ b/tests/assets/nested_option_fields.did @@ -7,6 +7,7 @@ type Fields = record { text_field : opt opt text; cfg_field : opt opt Cfg; named_field : opt Inner; + deep_field : opt opt opt text; }; service : { diff --git a/tests/conversion.test.ts b/tests/conversion.test.ts index 62345ad..972f878 100644 --- a/tests/conversion.test.ts +++ b/tests/conversion.test.ts @@ -88,6 +88,7 @@ describe('nested optional record fields', () => { text_field: [[]], cfg_field: [[{ url: ['https://example.org'] }]], named_field: [], + deep_field: [], }); }); @@ -96,15 +97,32 @@ describe('nested optional record fields', () => { text_field: [[]], cfg_field: [[{ url: ['https://example.org'] }]], named_field: [], + deep_field: [], }); expect(received).toEqual({ text_field: null, cfg_field: { url: 'https://example.org' }, named_field: undefined, + deep_field: undefined, }); }); + it('keeps a deeper option in the nested representation', async () => { + const [fields] = await sentBy('nested_option_fields', 'send_fields', { + deep_field: { __kind__: 'Some', value: null }, + }); + expect(fields).toMatchObject({ deep_field: [[[]]] }); + + const received = await receivedBy('nested_option_fields', 'get_fields', { + text_field: [], + cfg_field: [], + named_field: [], + deep_field: [[[]]], + }); + expect(received).toMatchObject({ deep_field: { __kind__: 'Some', value: null } }); + }); + it('round-trips a field declared through a named option', async () => { const [fields] = await sentBy('nested_option_fields', 'send_fields', { named_field: null }); expect(fields).toMatchObject({ named_field: [[]] }); @@ -113,6 +131,7 @@ describe('nested optional record fields', () => { text_field: [], cfg_field: [], named_field: [['set']], + deep_field: [], }); expect(received).toMatchObject({ named_field: 'set' }); }); From ec2e22bd2ed09028c761bdf07f206efe1fae9c31 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 28 Sep 2026 17:43:48 +0200 Subject: [PATCH 3/3] test: remove the wrapper receivedBy loads --- tests/conversion.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conversion.test.ts b/tests/conversion.test.ts index 972f878..70b8337 100644 --- a/tests/conversion.test.ts +++ b/tests/conversion.test.ts @@ -37,7 +37,7 @@ async function sentBy(serviceName: string, method: string, ...args: unknown[]) { async function receivedBy(serviceName: string, method: string, returned: unknown) { const actor: RawActor = new Proxy({}, { get: () => async () => returned }); const loaded = await loadWrapper(serviceName, actor); - cleanup = loaded.cleanup; + cleanups.push(loaded.cleanup); return loaded.wrapper[method](); }