Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/src/content/docs/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,44 @@ type MyType = {

</div>

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`:

<div class="code-comparison">

<div class="title-left">Candid</div>

<div class="code-left">

```
type MyType = record {
my_field : opt opt text;
}
```

</div>

<div class="title-right">TypeScript</div>

<div class="code-right">

```typescript
type MyType = {
my_field?: string | null;
};
```

</div>

</div>

| 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<string | null> | None`.

#### Variants

Candid [variants](https://github.com/dfinity/candid/blob/master/spec/Candid.md#variants) without type parameters are represented as TypeScript enums:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?: <ty>`, 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() {
Expand Down Expand Up @@ -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())],
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions tests/assets/nested_option_fields.did
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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;
deep_field : opt opt opt text;
};

service : {
send_fields : (Fields) -> ();
get_fields : () -> (Fields) query;
}
68 changes: 68 additions & 0 deletions tests/conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
cleanups.push(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', {
Expand Down Expand Up @@ -68,3 +75,64 @@ 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: [],
deep_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: [],
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: [[]] });

const received = await receivedBy('nested_option_fields', 'get_fields', {
text_field: [],
cfg_field: [],
named_field: [['set']],
deep_field: [],
});
expect(received).toMatchObject({ named_field: 'set' });
});
});
Loading