Skip to content

fix: subtyping and coercion rules for optional types - #678

Merged
mraszyk merged 6 commits into
masterfrom
mraszyk/optional-types-spec
Oct 7, 2025
Merged

mraszyk merged 6 commits into
masterfrom
mraszyk/optional-types-spec

Conversation

@mraszyk

@mraszyk mraszyk commented Sep 18, 2025 •

Copy link
Copy Markdown
Contributor

This PR fixes the specification of subtyping and coercion rules for optional types.

The changes to the specification are implemented in a separate PR.

Subtyping rules

Subtyping fix 1

The specification claims that the rules allow "any type to be regarded
as a subtype of an option".

Hence, the pre-condition of the rule

not (<datatype> <: opt <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>

is always false and this rule can be dropped.

Subtyping fix 2

The existing subtyping rule

not (null <: <datatype'>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>

should be fixed to

not (null <: <datatype>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>

according to the specification "It can also be specialised to its
constituent type, unless that type is itself optional". Also the fact
that nat <: opt opt nat cannot be derived otherwise.

Subtyping fix 3

The pre-condition not (null <: <datatype'>) in the existing
subtyping rule

not (null <: <datatype'>)

not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>

effectively only forbids null as <datatype'> because every other
<datatype'> such that null <: <datatype'> would also satisfy
<datatype> <: <datatype'> and thus violate the other
pre-condition. Since the corresponding coercion rule coerces to null in
this case anyway, we should enable null as <datatype'> in this rule
(as otherwise a separate rule to cover this case is needed with the same
coercion rule). Hence, the updated rule should read

not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>

Subtyping fix 4

Analogously to the existing rule


----------------------

null <: opt <datatype>

we need to add a new rule


--------------------------

reserved <: opt <datatype>

otherwise the type reserved is not a subtype of an option (contrary to
the claim that every type is a subtype of an option).

Subtyping fix 5

The specification claims that the rules allow "any type to be regarded
as a subtype of an option". Hence, we need to add the rule

not (null <: <datatype>)

not (<datatype> <: <datatype'>)

-----------------------------

<datatype> <: opt <datatype'>

otherwise the type nat is not a subtype of opt nat64.

Fixed subtyping rules

The final set of subtyping rules for optional types is

----------------------

null <: opt <datatype>

--------------------------

reserved <: opt <datatype>
<datatype> <: <datatype'>

---------------------------------

opt <datatype> <: opt <datatype'>
not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>
not (null <: <datatype>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>
not (null <: <datatype>)

not (<datatype> <: <datatype'>)

-----------------------------

<datatype> <: opt <datatype'>

and it is easy to see that these rules are non-overlapping and
correspond to coercion rules.

Coercion rules

Coercion fix 1

The rule


---------------------------------

<v> : reserved ~> null : opt <t'>

is overlapping with

null <: <t>

-----------------------------

null : <t> ~> null : opt <t'>

and thus the former should be fixed to

not (<v> = null)

-----------------------------

<v> : reserved ~> null : opt <t'>

Coercion fix 2

The coercion rule

not (null <: <t>)

not (null <: <t'>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>

has a pre-condition not (null <: <t'>) that does not correspond to
any pre-condition in the corresponding subtyping rule (the fixed rule in
the section "Subtyping fix 2") and prevents coercion of nat into opt opt nat
(for instance). Hence, we drop the pre-condition and the updated
rule is

not (null <: <t>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>

Coercion fix 3

The rule

not (null <: <t'>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>

allows opt 5 : opt nat to coerce to null : opt nat which does not make
sense as it loses data (opt 5) that could be preserved. This rule should
be extended with an additional pre-condition not (null <: <t>) since
there are separate coercion rules for such cases.

Coercion fix 4

The rule

null <: <t'>

not (null <: <t>)

----------------------------

<v> : <t> ~> null : opt <t'>

allows 5 : nat to coerce to null : opt opt nat which does not make
sense. This rule should be extended with an additional pre-condition

not (exists <w>. <v> : <t> ~> <w> : <t'>)

to only coerce to null : opt <t'> if coercion to <t'> failed.

Coercion fix 5

But now we easily see that the two rules

not (null <: <t>)

not (null <: <t'>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>

and

not (null <: <t>)

null <: <t'>

not (exists <w>. <v> : <t> ~> <w> : <t'>)

----------------------------

<v> : <t> ~> null : opt <t'>

can be merged into a single rule

not (null <: <t>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>

Fixed coercion rules

The final set of coercion rules is

not (<v> = null)

---------------------------------

<v> : reserved ~> null : opt <t'>
null <: <t>

-----------------------------

null : <t> ~> null : opt <t'>
<v> : <t> ~> <v'> : <t'>

----------------------------------------

opt <v> : opt <t> ~> opt <v'> : opt <t'>
not (exists <w>. <v> : <t> ~> <w> : <t'>)

----------------------------------------

opt <v> : opt <t> ~> null : opt <t'>
not (null <: <t>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>
not (null <: <t>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>

and it is easy to see that these rules are non-overlapping and
correspond to subtyping rules.

@mraszyk
mraszyk requested a review from a team as a code owner September 18, 2025 17:06
@github-actions

github-actions Bot commented Sep 18, 2025 •

Copy link
Copy Markdown
Name Max Mem (Kb) Encode Decode
blob 4_224 4_207_740 2_122_616
btreemap 75_456 4_701_848_877 15_692_380_061
nns 128 2_022_530 5_644_704 ($\textcolor{green}{-0.07\%}$)
nns_list_proposal 1_088 6_883_839 ($\textcolor{green}{-0.09\%}$) 68_466_700 ($\textcolor{green}{-0.00\%}$)
option_list 128 8_027_371 27_126_568
text 6_336 4_204_277 7_877_882
variant_list 128 8_224_338 25_454_961
vec_int16 16_704 123_695_930 1_017_143_118
  • Parser cost: 17_831_723 ($\textcolor{red}{0.00\%}$)
  • Extra args: 3_333_990
Click to see raw report
---------------------------------------------------

Benchmark: blob
  total:
    instructions: 6.33 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.21 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 2.12 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: text
  total:
    instructions: 12.08 M (no change)
    heap_increase: 99 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.20 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 7.88 M (no change)
    heap_increase: 33 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: vec_int16
  total:
    instructions: 1.14 B (no change)
    heap_increase: 261 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 123.70 M (no change)
    heap_increase: 261 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 1.02 B (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: btreemap
  total:
    instructions: 20.39 B (no change)
    heap_increase: 1179 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.70 B (no change)
    heap_increase: 159 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 15.69 B (no change)
    heap_increase: 1020 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: option_list
  total:
    instructions: 35.16 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 8.03 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 27.13 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: variant_list
  total:
    instructions: 33.68 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 8.22 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 25.45 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: nns
  total:
    instructions: 26.45 M (-0.01%) (change within noise threshold)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  0. Parsing (scope):
    calls: 1 (no change)
    instructions: 17.83 M (0.00%) (change within noise threshold)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 2.02 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 5.64 M (-0.07%) (change within noise threshold)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: nns_list_proposal
  total:
    instructions: 75.35 M (-0.01%) (change within noise threshold)
    heap_increase: 17 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 6.88 M (-0.09%) (change within noise threshold)
    heap_increase: 3 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 68.47 M (-0.00%) (change within noise threshold)
    heap_increase: 14 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: extra_args
  total:
    instructions: 3.33 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min -6.84K]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min -0.01%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
Successfully persisted results to canbench_results.yml

Comment thread spec/Candid.md Outdated
Comment thread spec/Candid.md Outdated

@crusso crusso 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.

Looks good to me and I think the rules all match up with your final fixes in the doc.
To be honest, it might be good to include the doc in the PR description, in case anyone want to understand the PR later.

@crusso crusso 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.

Still looks good to me.

(Motoko will need to change its implementation too is seems caffeinelabs/motoko#5504)

@mraszyk
mraszyk merged commit 98eaa21 into master Oct 7, 2025
11 checks passed
@mraszyk
mraszyk deleted the mraszyk/optional-types-spec branch October 7, 2025 09:31
ggreif added a commit to caffeinelabs/motoko that referenced this pull request Nov 6, 2025
## Implement Martin's revised deserialization for Candid options

> This PR fixes coercion of values into nested optional types.

>Before the fix from this PR, `5 : nat64` would coerce to `opt 5 : opt
na64` (as expected) and `null : opt opt nat64` (unexpected). >This PR
fixes the unexpected result to be `opt opt 5 : opt opt nat64` (as
expected).

>Before the fix from this PR, `5 : int` would coerce to `null : opt opt
nat`. This PR fixes the result to be `opt null : opt opt nat`. The
>motivation for this change is to enable using optional types as
follows:
>- outer option characterizes if a value (of any type) is provided;
>- inner option expresses to optimistically deserialize the actual value
into the given type (without failing to deserialize and without losing
the information if a value of any type is provided).

>This PR also adds multiple test cases covering the change to coercion
(including coercion to nested optional types over `null` and
`reserved`).

c.f.
*  Rust fix dfinity/candid#679
* Candid spec [PR](dfinity/candid#678), in
particular this commit

dfinity/candid@90af305

## Furthermore (from #5619) fixes #5543.

The spec says that every Candid value is a subtype of `opt T`, and gives
`opt t` when `t <: T`. But the `null` type has no subtypes. I.e. we only
get out a `null : Null` when the binary Candid has a `0x7F` there. In
all other cases the coercion must fail, either trapping or indicating a
coercion failure to the caller (when recoverable). However `null : ?T`
can always be materialised when an argument is missing, similarly `null
: Null`. So we have to treat missing arguments and coercion failures
differently when the requested type is `Null` (or `null` respectively,
in Candid).

This PR corrects the reading of (e.g. `\x70`, `<reserved>`) in the
non-recoverable case and makes it a coercion failure.

Also adds improvements to the `candid-tests` runner to
- not ignore the case when a field type can be inferred, but not in the
record type (eliminating `M0164`, ignored test)
- adds `null` values to top-level argument tuples where the type
requests them
- adds `null` fields to record values (in argument sequences) where the
type requests them

------------------
TODO Tasks:
- [x] bump candid dependency to pick up revised test suite
- [x] It would be cool if the generated Motoko would be output on
`stderr` when `--diag` is passed to `candid-tests`
- [ ] potential simplifications suggested in #5619

---------

Co-authored-by: Gabor Greif <gabor@dfinity.org>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: pr-automation-bot-public[bot] <189003650+pr-automation-bot-public[bot]@users.noreply.github.com>
lwshang added a commit that referenced this pull request Oct 21, 2026
## Summary

`type Opt = opt Opt` is an *infinite option*: an option whose
constituent is again an option, forever. The spec's coercion rules don't
say what happens when a value whose type does not accept `null` (say
`true : bool`) is coerced at one. The implementations and the test suite
agree that decoding fails. This PR writes that into the spec. No
implementation changes.

## Why the spec has a gap here

Before #678, the rule via the constituent type had a premise `not (null
<: <t'>)`, so it never unwrapped an option inside an option. Coercion
was well founded, and `true : bool` at `Opt` gave `null`
(test/construct.test.did asserted exactly that, from #126).

#678 dropped the premise so that `5 : nat` coerces to `opt opt 5 : opt
opt nat`. At an infinite option, that rule now asks for a coercion of
the same value one level down, and the rule that goes to `null` applies
only if that coercion doesn't exist. So the answer depends on its own
negation, and the spec's own soundness property (`nat <: Opt`, so some
coercion should exist) contradicts any reading of it.

## What the implementations do

- **`candid` ≥ 0.10.20 (#679) rejects.** It keeps unwrapping the
expected type until its recursion limit. That error is not a `Subtype`
error, so the `opt` rule's recovery doesn't catch it, and the whole
message fails, even under an enclosing `opt`, in a record field, or in a
vector element. Probed directly; the new assertions cover those cases.
- **test/construct.test.did** has asserted `!:` for `true : bool` at
`Opt` since #679. The review comment there: `true : bool` "cannot coerce
into an _infinitely_ nested optional type".
- **Motoko** runs this repo's `test/` suite in CI with no
expected-failure list, and #679's approver ported the change there
(caffeinelabs/motoko#5505). The new assertions will check the
enclosing-`opt`, record and vector cases on Motoko's side when it picks
up this suite.

## The change

- **`spec/Candid.md`, Coercion › Options:**
  - Defines an infinite option.
- States that coercing a value whose type does not accept `null` at one
is *undefined*. That is distinct from failing to coerce: a `not (exists
<w>. ...)` premise only holds where coercion is defined.
  - So no enclosing option recovers, and deserialisation fails.
- **`spec/Candid.md`, Properties:** soundness states this as its
exception.
- **`test/construct.test.did`:** new assertions:
- the mutually recursive case (`type OptA = opt OptB; type OptB = opt
OptA`)
  - an enclosing option (`opt true : opt bool` at `Opt`)
  - a record field and a vector element at `Opt`
- an empty `vec bool` at `vec Opt`, which decodes, since there is
nothing to coerce.

Each rejected blob was checked to decode at a compatible type, so it
fails because of the cycle and not because the bytes are malformed.
- **`CHANGELOG.md`:** an `Unreleased` spec entry.

The spec's `Version: 0.1.8` line is unchanged, as with #678 and #748.

## Follow-up

The Lean model on `rewrite` (#777) currently answers `null` here. Once
this lands it will follow the spec: `coerce` gets an "undefined" outcome
that no `opt` absorbs, and the vector that is currently a known
divergence becomes an agreement.

## Test

```
cargo test -p candid_parser --test test_suite
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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