Skip to content
Merged
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
152 changes: 125 additions & 27 deletions rfcs/0005-expression-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,34 +755,109 @@ The `T` type for a `list[T]` must satisfy:
### Implicit Type Coercion

Implicit non-destructive type coercion is performed where the intent is obvious. The caller
provides a set of target types it expects, and we use this to affect coercion decisions.
When the expression result does not directly match a target type, the following implicit
conversions are attempted:

- `int` → `float` when the target types do not include `int`
- `path` → `string` when the target types do not include `path`
- `range_expr` → `string` when the target types do not include `range_expr` (produces canonical form like `"1-5"`)
- `range_expr` → `list[int]` when the target types include `list[int]` but not `range_expr`.
This is the only list type a `range_expr` implicitly coerces to: a `list[T]` target with any
other element type (e.g. `list[float]` or `list[string]`) is an error. (A `list[any]` target
is also accepted, since a `list[int]` value already satisfies it.) Implicit rules do not
chain, so the materialized `list[int]` is not further widened element-wise toward the target.
provides the target type it expects, which need not be a single concrete type: it may be
`any`, a union such as `string?` or `string? | list[string]`, or a list whose element type
is either of those. Coercion asks two questions about the expression result, in this order.

**1. Satisfaction.** Does the result's type already satisfy the target? If so, the value is
used unchanged and no conversion is attempted. A type satisfies a target when:

- the target is `any`, which every type satisfies;
- the target is a union and *any* one of its members is satisfied, so an `int` satisfies
`int | string` and a `null` satisfies `T?`;
- both are lists and the result's element type satisfies the target's element type, so a
`list[int]` satisfies `list[any]` and `list[int | string]`;
- or the two types are equal.

A result's type is never itself a union: concrete values have a single runtime type, and
for the not-yet-known values checked during validation, a union constraint is decomposed
into its members before these steps apply (see
[Coercion of Unresolved Values](#coercion-of-unresolved-values)).

Satisfaction is directional: `int` satisfies `any`, but `any` does not satisfy `int`. It is
therefore not the same relation as the symmetric matching used to bind type variables during
signature matching, and an implementation must not use one for the other — the symmetric
relation would accept a `list[T1]` target by binding `T1` and discarding the binding.

Because satisfaction is checked first, a result whose type the target already admits is
never converted: an `int` against `int | string` stays an `int` rather than becoming a
string. Consequently, a conversion is only ever attempted toward a type the result does
not already have.

**2. Conversion.** Otherwise the result is converted toward one of the target's
*destinations*. A destination is a single non-union type toward which one of the
conversions below can be attempted. A non-union target is its own only destination. A union
target contributes each of its members, because producing any one of them satisfies the
union. Destinations are attempted in order and the first that converts gives the result; a
destination that fails is not an error so long as a later one succeeds, and the coercion
fails only if none converts.

Destinations are attempted in a fixed order with two levels. **Non-list destinations come
before list destinations**: converting to a scalar produces a single value whose cost does
not depend on the source, while converting to a list materializes its elements and may
exceed an implementation's size limits, so this ordering keeps the outcome independent of
how large the value is. Within each of the two groups, the order is determined by the
result's type, following two principles: a value prefers to stay within its own kind — a
number remains a number before it becomes text — and a conversion that can fail is
attempted before one that always succeeds, because a universal fallback attempted first
would make every destination after it unreachable.

| Result type | Destinations, in order | Notes |
|-------------|------------------------|-------|
| `bool` | `string` | only conversion |
| `int` | `float`, then `string` | stays a number first; text is the universal fallback |
| `float` | `int`, then `string` | `int` succeeds only for exact whole values, so `3.0` against `int \| string` gives `3` while `3.5` gives `"3.5"` |
| `string` | `int`, then `float`, then `bool`, then `range_expr`, then `path` | every string that parses as `int` also parses as `float`, so `int` must come first; `bool` and `range_expr` are selective parses, tried after the numeric ones; every string is a valid `path`, so `path` comes last |
| `path` | `string` | only conversion |
| `range_expr` | `string`, then `list[int]` | the non-list-first rule; when a target offers both, the `list[int]` destination is unreachable, and a template that wants the list uses the explicit `list()` conversion (RFC 0006) |
| `list[S]` | list destinations in `S`'s order, applied to their element types | `list[float]` against `list[int] \| list[string]` attempts `list[int]` first |

So `5` against `float | string` becomes `5.0`, and `"5"` against `int | float` becomes `5`
while `"5.0"` becomes `5.0` — the stricter parse is attempted first, and the string's own
lexical form routes it. An empty list (`list[nulltype]`) converts to every list type and
the result is the empty list regardless; the nominal element type it carries follows the
first list destination in the union's normalized member order.

`nulltype` is never a destination, because no conversion produces `null`. A `null` result
reaches a `T?` target by satisfying it, and a `nulltype` member is otherwise ignored when
converting; in particular, a `string` whose text happens to be `"null"` does not become
`null`. Likewise a type variable, `noreturn`, `unresolved[T]`, or a `list` parameterized by
any of those contributes no destination, so a target composed only of such types cannot be
coerced to at all.

Because destinations are considered one at a time, a union target accepts at least
everything each of its members accepts on its own. Adding an alternative to a target never
takes away a conversion that was previously accepted, though it can change which
destination is chosen, and with it the resulting value.

The non-destructive conversions are:

- `bool`/`int`/`float`/`path`/`range_expr` → `string` (a `range_expr` produces its canonical
form, like `"1-5"`)
- `string` → `path`
- `float`/`string` → `int` (error if the value cannot be represented exactly, e.g. `3.75`,
`""`, `"nothing"`, `"3.1"`)
- `int`/`string` → `float` (error if the string cannot be parsed, e.g. `""`, `"nothing"`)
- `string` → `bool`, accepting the same case-insensitive spellings as the explicit `bool()`
conversion (RFC 0006): `"true"`/`"yes"`/`"on"`/`"1"` become `true` and
`"false"`/`"no"`/`"off"`/`"0"` become `false` (error otherwise)
- `string` → `range_expr` (error if the string does not parse as a range expression, e.g.
`""`, `"1-"`)
- `range_expr` → `list[int]`. This is the only list type a `range_expr` implicitly coerces
to, so the destination is accepted exactly when a `list[int]` value would satisfy it —
`list[int]`, `list[any]`, and `list[int | string]` — and rejected for any other element
type, such as `list[float]` or `list[string]`. Implicit rules do not chain, so the
materialized `list[int]` is not further widened element-wise toward the destination.
A template that wants the widened list can chain the explicit conversion
`list(value: range_expr) -> list[int]` (RFC 0006) — e.g. `list(r)` in a `list[string]`
context — where the `list[T]` → `list[U]` rule below then applies to the conversion's result.
- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`)
- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`).
This applies recursively for nested lists.
- `list[nulltype]` → `list[T]` for any `T` (empty list literal is compatible with any list type)
- Any scalar value when the target types have a single scalar type (without counting `nulltype` or `list[T]` for any `T`).
The value is coerced non-destructively to that type using the non-destructive coercion rules below.
For example, in a format string context where the target type is `string?`,
an `int` result is coerced to `string`.
- `bool`/`int`/`float`/`path` → `string`
- `string` → `path`
- `float`/`string` → `int` (error if value cannot be represented exactly, e.g. `3.75`, `""`, `"nothing"`, `"3.1"`)
- `int`/`string` → `float` (error if string cannot be parsed, e.g. `""`, `"nothing"`)
- `[v1, v2, v3, ...]` any values when the target types have a single `list` type (without counting `list[nulltype]`).
Every value is coerced non-destructively to `T` where that type is `list[T]`. This applies recursively
for nested lists. The non-destructive coercions are the same as defined for scalar values above.

For example, in a format string context where the target type is `string?`, an `int` result
is coerced to `string`: it does not satisfy `string?`, whose destinations are just `string`
once the `nulltype` member is set aside, and `int` → `string` is in the table above.

A **type variable** target (`T`, `T1`, `T2`, `T3`) has no coercion rule, for concrete and
unresolved values alike. Type variables are placeholders in generic function signatures,
Expand All @@ -791,13 +866,36 @@ Target Type Propagation Rules in [Expression Evaluation](#expression-evaluation)
one still unbound is always an error. Accepting a type-variable target for unresolved values
would let validation pass an expression that can only fail once the value is known.

This holds at any nesting depth: a `list[T1]` target is no more usable than a `T1` target,
since a list cannot be produced without knowing its element type. An implementation must
reject a `list` destination whose element type mentions an unbound type variable, rather
than binding the variable and discarding the binding.

#### Coercion of Unresolved Values

Target-type coercion applies the same conversion table to `unresolved[T]` values at the type
level: the payload remains unresolved, but its constraint is narrowed to the coercion result.
For example, `unresolved[int]` against a `string` target becomes `unresolved[string]`, and a
Target-type coercion applies the same two steps to `unresolved[T]` values at the type level:
the payload remains unresolved, but its constraint is narrowed to the coercion result. For
example, `unresolved[int]` against a `string` target becomes `unresolved[string]`, and a
`range_expr` → `list[int]` coercion of an unresolved value yields `unresolved[list[int]]`.

The narrowed constraint is the type the applicable step produces, which is not always the
target. Satisfaction leaves the value alone, so its constraint keeps the source type: an
`unresolved[list[int]]` against a `list[any]` target stays `unresolved[list[int]]` rather than
widening to `unresolved[list[any]]`, matching the concrete `list[int]` value that would be
returned unchanged. Conversion yields the type its own rule produces, so an
`unresolved[range_expr]` against `list[any]` becomes `unresolved[list[int]]`, because
materializing a range only ever produces a `list[int]`.

The narrowed constraint always satisfies the target, and it always describes the concrete
result: the concrete result's type satisfies the narrowed constraint. The type level cannot
see the payload that decides which destination of a union target wins, so conversion narrows
to the union of every destination with a type-level rule, rather than betting on any one of
them — anything narrower would misdescribe some resolved value. Against an `int | string`
target an `unresolved[float]` narrows to `unresolved[int | string]`: a `3.0` payload takes
the `float` → `int` rule while a `3.5` payload fails it and falls through to `string`, and
both outcomes lie within the constraint. For a non-union target exactly one destination
exists, so the constraint is exactly the type evaluation will produce.

When the constraint is a union, coercion is existential: it succeeds if at least one member of
the union can coerce to the target, and the possibilities that cannot are discarded. Coercing
`unresolved[int | string]` to an `int` target yields `unresolved[int]`, and
Expand Down
66 changes: 46 additions & 20 deletions wiki/2026-02-Expression-Language.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,27 +849,53 @@ representation of frame ranges. Use `list(Task.Param.Frame)` to convert to a lis
#### 1.2.3. Implicit Type Coercion

As a glue expression language intended for convenience, implicit non-destructive type
coercion is performed where the intent is obvious. The following implicit conversions are supported:

- `int` → `float` when the target types do not include `int`
- `path` → `string` when the target types do not include `path`
- `range_expr` → `string` when the target types do not include `range_expr` (produces canonical form like `"1-5"`)
- `range_expr` → `list[int]` when the target types include `list[int]` but not `range_expr`.
This is the only list type a `range_expr` implicitly coerces to: a `list[T]` target with any
other element type (e.g. `list[float]` or `list[string]`) is an error. (A `list[any]` target
is also accepted, since a `list[int]` value already satisfies it.) Implicit rules do not
chain, so the materialized `list[int]` is not further widened element-wise. Use the explicit
conversion `list(r)` to get a `list[int]` value that the `list[T]` → `list[U]` rule then applies to.
- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`)
coercion is performed where the intent is obvious.

Each field gives the expression a target type, which may be a single type, an optional type
like `string?`, or a choice like `string? | list[string]` for a list item. Coercion happens in
two steps:

1. **If the result already fits the target, it is used as is.** A value fits when its type is
the target, or is one of the target's choices, or the target is `any`. A `list[int]` also
fits `list[any]` and `list[int | string]`, since its elements fit the element type.
2. **Otherwise the result is converted** toward one of the target's types, using the
conversions below. Where the target offers a choice, each is tried in turn and the first
that works is used. Non-list types are tried before list types, and within that, the
result's type sets the order: a number prefers to stay a number before becoming text, and
conversions that always succeed (anything → `string`, any `string` → `path`) are tried
last, since trying them first would make the others unreachable. So `5` against
`float | string` becomes `5.0`, `"5"` against `int | float` becomes `5` while `"5.0"`
becomes `5.0`, and `list[float]` against `list[int] | list[string]` tries `list[int]`
first. The full ordering table is in RFC 0005.

Because of the first step, a value is never converted when the target already accepts it —
an `int` stays an `int` for an `int | string` target rather than becoming a string.

A `nulltype` choice is never converted *into*: `null` reaches a `T?` target only by already
being `null`, so a string whose text happens to be `"null"` stays a string. Because non-list
types are tried first, a `range_expr` against `list[int] | string` becomes the string `"1-5"`
rather than the expanded list.

The conversions are:

- `bool`/`int`/`float`/`path`/`range_expr` → `string` (a `range_expr` produces its canonical
form, like `"1-5"`)
- `string` → `path`
- `float`/`string` → `int` (error if value cannot be represented exactly, e.g. `3.75`, `""`, `"3.1"`)
- `int`/`string` → `float` (error if string cannot be parsed, e.g. `""`, `"nothing"`)
- `string` → `bool`, accepting the same case-insensitive spellings as the explicit `bool()`
conversion: `"true"`/`"yes"`/`"on"`/`"1"` become `true` and `"false"`/`"no"`/`"off"`/`"0"`
become `false` (error otherwise)
- `string` → `range_expr` (error if the string is not a valid range expression, like `"1-10"`)
- `range_expr` → `list[int]`. This is the only list type a `range_expr` implicitly coerces to,
so it is accepted for a target a `list[int]` would fit — `list[int]`, `list[any]`, or
`list[int | string]` — and is an error for any other element type, such as `list[float]` or
`list[string]`. Implicit rules do not chain, so the materialized `list[int]` is not further
widened element-wise. Use the explicit conversion `list(r)` to get a `list[int]` value that
the `list[T]` → `list[U]` rule then applies to.
- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`).
This applies recursively for nested lists.
- `list[nulltype]` → `list[T]` for any `T` (empty list literal is compatible with any list type)
- Any scalar value when the target types have a single scalar type. The value is coerced
non-destructively to that type:
- `bool`/`int`/`float`/`path` → `string`
- `string` → `path`
- `float`/`string` → `int` (error if value cannot be represented exactly, e.g. `3.75`, `""`, `"3.1"`)
- `int`/`string` → `float` (error if string cannot be parsed, e.g. `""`, `"nothing"`)
- `[v1, v2, ...]` any values when the target types have a single `list` type. Every value is
coerced non-destructively to `T` where that type is `list[T]`. This applies recursively for nested lists.

#### 1.2.4. Method Call Coercion Restriction

Expand Down