Attributes System
This document defines the attributes system for Morphir IR v4, replacing the generic type parameter approach with structured, semantic attributes.
The linked-metadata draft develops attributes.facts and scoped predicates for a future V4 minor revision. It does not change the 4.0.0 attribute encoding described here.
Overview
Problem
In IR v3, types and values are parameterized by a generic attribute type:
// v3 approach
pub type Type(a) {
Variable(attributes: a, name: TypeVariable)
Reference(attributes: a, fqname: FQName, args: List(Type(a)))
// ...
}
This has limitations:
- No standard structure for common metadata (source locations, constraints)
- Backends must pattern-match on arbitrary attribute types
- No way to express semantic constraints (numeric bounds, encodings)
- Extension points are ad-hoc
Solution
Replace the generic parameter with structured attribute types that have:
- Well-known fields for common concerns (source location, constraints)
- Extension map for user-defined metadata (RDF-like triples)
- Separate types for Type vs. Value attributes (different semantic needs)
Design
TypeAttributes
Attributes attached to type expressions.
- Gleam
- Python
- Java 21+
pub type TypeAttributes {
TypeAttributes(
/// Source location for error reporting
source: Option(SourceLocation),
/// Semantic constraints on values of this type
constraints: Option(TypeConstraints),
/// User-defined extensions (predicate -> value)
extensions: Dict(FQName, Value),
)
}
/// Empty attributes (replaces unit `()` from v3)
pub const empty_type_attributes = TypeAttributes(
source: None,
constraints: None,
extensions: dict.new(),
)
from dataclasses import dataclass, field
from typing import Optional
@dataclass(frozen=True)
class TypeAttributes:
"""Attributes attached to type expressions."""
source: Optional[SourceLocation] = None
constraints: Optional[TypeConstraints] = None
extensions: dict[FQName, Value] = field(default_factory=dict)
# Empty attributes
EMPTY_TYPE_ATTRIBUTES = TypeAttributes()
public record TypeAttributes(
Optional<SourceLocation> source,
Optional<TypeConstraints> constraints,
Map<FQName, Value> extensions
) {
public static final TypeAttributes EMPTY = new TypeAttributes(
Optional.empty(),
Optional.empty(),
Map.of()
);
}
ValueAttributes
Attributes attached to value expressions.
- Gleam
- Python
- Java 21+
pub type ValueAttributes {
ValueAttributes(
/// Source location for error reporting
source: Option(SourceLocation),
/// Inferred or annotated type
inferred_type: Option(Type),
/// Value-level constraints/properties
properties: Option(ValueProperties),
/// User-defined extensions
extensions: Dict(FQName, Value),
)
}
pub type ValueProperties {
ValueProperties(
/// Is this a compile-time constant?
is_constant: Bool,
/// Purity: does this have side effects?
purity: Purity,
)
}
pub type Purity {
Pure // No side effects
Effectful // Has effects (IO, state, etc.)
Unknown // Not yet analyzed
}
pub const empty_value_attributes = ValueAttributes(
source: None,
inferred_type: None,
properties: None,
extensions: dict.new(),
)
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
class Purity(Enum):
PURE = auto()
EFFECTFUL = auto()
UNKNOWN = auto()
@dataclass(frozen=True)
class ValueProperties:
"""Value-level constraints and properties."""
is_constant: bool = False
purity: Purity = Purity.UNKNOWN
@dataclass(frozen=True)
class ValueAttributes:
"""Attributes attached to value expressions."""
source: Optional[SourceLocation] = None
inferred_type: Optional["Type"] = None
properties: Optional[ValueProperties] = None
extensions: dict[FQName, Value] = field(default_factory=dict)
EMPTY_VALUE_ATTRIBUTES = ValueAttributes()
public enum Purity { PURE, EFFECTFUL, UNKNOWN }
public record ValueProperties(
boolean isConstant,
Purity purity
) {
public static final ValueProperties DEFAULT =
new ValueProperties(false, Purity.UNKNOWN);
}
public record ValueAttributes(
Optional<SourceLocation> source,
Optional<Type> inferredType,
Optional<ValueProperties> properties,
Map<FQName, Value> extensions
) {
public static final ValueAttributes EMPTY = new ValueAttributes(
Optional.empty(),
Optional.empty(),
Optional.empty(),
Map.of()
);
}
TypeConstraints
Semantic constraints on types, particularly for numeric and string types.
- Gleam
- Python
- Java 21+
pub type TypeConstraints {
TypeConstraints(
numeric: Option(NumericConstraint),
string: Option(StringConstraint),
collection: Option(CollectionConstraint),
custom: List(CustomConstraint),
)
}
pub type NumericConstraint {
/// Arbitrary precision (default for Int)
Arbitrary
/// Fixed-width signed integer
Signed(bits: IntWidth)
/// Fixed-width unsigned integer
Unsigned(bits: IntWidth)
/// Floating point
FloatingPoint(bits: FloatWidth)
/// Bounded range (JSON Schema style)
Bounded(min: Option(BigInt), max: Option(BigInt))
/// Fixed-point decimal
Decimal(precision: Int, scale: Int)
}
pub type IntWidth {
I8
I16
I32
I64
}
pub type FloatWidth {
F32
F64
}
pub type StringConstraint {
StringConstraint(
encoding: Option(StringEncoding),
min_length: Option(Int),
max_length: Option(Int),
pattern: Option(String), // Regex pattern
)
}
pub type StringEncoding {
UTF8
UTF16
ASCII
Latin1
}
pub type CollectionConstraint {
CollectionConstraint(
min_length: Option(Int),
max_length: Option(Int),
unique_items: Bool,
)
}
pub type CustomConstraint {
CustomConstraint(
predicate: FQName,
arguments: List(Value),
)
}
from dataclasses import dataclass
from typing import Optional, Literal
from enum import Enum, auto
IntWidth = Literal[8, 16, 32, 64]
FloatWidth = Literal[32, 64]
@dataclass(frozen=True)
class Signed:
bits: IntWidth
@dataclass(frozen=True)
class Unsigned:
bits: IntWidth
@dataclass(frozen=True)
class FloatingPoint:
bits: FloatWidth
@dataclass(frozen=True)
class Bounded:
min: Optional[int] = None
max: Optional[int] = None
@dataclass(frozen=True)
class Decimal:
precision: int
scale: int
# Union type for NumericConstraint
# None represents Arbitrary
NumericConstraint = None | Signed | Unsigned | FloatingPoint | Bounded | Decimal
class StringEncoding(Enum):
UTF8 = auto()
UTF16 = auto()
ASCII = auto()
LATIN1 = auto()
@dataclass(frozen=True)
class StringConstraint:
encoding: Optional[StringEncoding] = None
min_length: Optional[int] = None
max_length: Optional[int] = None
pattern: Optional[str] = None
@dataclass(frozen=True)
class CollectionConstraint:
min_length: Optional[int] = None
max_length: Optional[int] = None
unique_items: bool = False
@dataclass(frozen=True)
class CustomConstraint:
predicate: FQName
arguments: list[Value]
@dataclass(frozen=True)
class TypeConstraints:
numeric: Optional[NumericConstraint] = None
string: Optional[StringConstraint] = None
collection: Optional[CollectionConstraint] = None
custom: list[CustomConstraint] = None
public sealed interface NumericConstraint {
record Arbitrary() implements NumericConstraint {}
record Signed(int bits) implements NumericConstraint {
public Signed {
if (bits != 8 && bits != 16 && bits != 32 && bits != 64)
throw new IllegalArgumentException("bits must be 8, 16, 32, or 64");
}
}
record Unsigned(int bits) implements NumericConstraint {
public Unsigned {
if (bits != 8 && bits != 16 && bits != 32 && bits != 64)
throw new IllegalArgumentException("bits must be 8, 16, 32, or 64");
}
}
record FloatingPoint(int bits) implements NumericConstraint {
public FloatingPoint {
if (bits != 32 && bits != 64)
throw new IllegalArgumentException("bits must be 32 or 64");
}
}
record Bounded(Optional<BigInteger> min, Optional<BigInteger> max)
implements NumericConstraint {}
record Decimal(int precision, int scale) implements NumericConstraint {}
}
public enum StringEncoding { UTF8, UTF16, ASCII, LATIN1 }
public record StringConstraint(
Optional<StringEncoding> encoding,
OptionalInt minLength,
OptionalInt maxLength,
Optional<String> pattern
) {}
public record CollectionConstraint(
OptionalInt minLength,
OptionalInt maxLength,
boolean uniqueItems
) {}
public record CustomConstraint(FQName predicate, List<Value> arguments) {}
public record TypeConstraints(
Optional<NumericConstraint> numeric,
Optional<StringConstraint> string,
Optional<CollectionConstraint> collection,
List<CustomConstraint> custom
) {
public static final TypeConstraints EMPTY = new TypeConstraints(
Optional.empty(), Optional.empty(), Optional.empty(), List.of()
);
}
Updated Type Definition
With attributes, the Type definition becomes non-generic:
- Gleam
- Python
- Java 21+
// v4 approach (no generic parameter)
pub type Type {
Variable(attributes: TypeAttributes, name: TypeVariable)
Reference(attributes: TypeAttributes, fqname: FQName, args: List(Type))
Tuple(attributes: TypeAttributes, elements: List(Type))
Record(attributes: TypeAttributes, fields: List(Field))
ExtensibleRecord(attributes: TypeAttributes, variable: TypeVariable, fields: List(Field))
Function(attributes: TypeAttributes, arg: Type, result: Type)
Unit(attributes: TypeAttributes)
}
pub type Field {
Field(name: Name, field_type: Type)
}
@dataclass(frozen=True)
class Variable:
attributes: TypeAttributes
name: TypeVariable
@dataclass(frozen=True)
class Reference:
attributes: TypeAttributes
fqname: FQName
args: list["Type"]
@dataclass(frozen=True)
class Tuple:
attributes: TypeAttributes
elements: list["Type"]
@dataclass(frozen=True)
class Record:
attributes: TypeAttributes
fields: list[Field]
@dataclass(frozen=True)
class ExtensibleRecord:
attributes: TypeAttributes
variable: TypeVariable
fields: list[Field]
@dataclass(frozen=True)
class Function:
attributes: TypeAttributes
arg: "Type"
result: "Type"
@dataclass(frozen=True)
class Unit:
attributes: TypeAttributes
Type = Variable | Reference | Tuple | Record | ExtensibleRecord | Function | Unit
public sealed interface Type {
TypeAttributes attributes();
record Variable(TypeAttributes attributes, TypeVariable name) implements Type {}
record Reference(TypeAttributes attributes, FQName fqname, List<Type> args) implements Type {}
record Tuple(TypeAttributes attributes, List<Type> elements) implements Type {}
record Record(TypeAttributes attributes, List<Field> fields) implements Type {}
record ExtensibleRecord(TypeAttributes attributes, TypeVariable variable, List<Field> fields) implements Type {}
record Function(TypeAttributes attributes, Type arg, Type result) implements Type {}
record Unit(TypeAttributes attributes) implements Type {}
}
Extensions as RDF-like Triples
The extensions field in attributes acts as an RDF-like graph where:
- Subject: The IR node being annotated (implicit)
- Predicate: The
FQNamekey - Object: The
Value
This enables:
- User-defined decorators stored in IR
- Tool-specific metadata
- Custom linting/analysis rules
Example:
{
"Reference": {
"attributes": {
"extensions": {
"my-org/decorators:deprecated#reason": {
"Literal": { "literal": { "StringLiteral": { "value": "Use NewType instead" } } }
},
"my-org/decorators:since#version": {
"Literal": { "literal": { "StringLiteral": { "value": "2.0.0" } } }
}
}
},
"fqname": "my-org/domain:types#old-type"
}
}
Constraint Entailment (Subtyping)
Constraints form a partial order for subtype checking:
Signed { bits: 8 } ⊑ Signed { bits: 16 } ⊑ Signed { bits: 32 } ⊑ Signed { bits: 64 } ⊑ Arbitrary
Unsigned { bits: 8 } ⊑ Unsigned { bits: 16 } ⊑ ... ⊑ Arbitrary
Bounded { min: a, max: b } ⊑ Signed { bits: n } when a >= -(2^(n-1)) && b < 2^(n-1)
Bounded { min: a, max: b } ⊑ Unsigned { bits: n } when a >= 0 && b < 2^n
This enables:
- Implicit widening:
Int32value assigned toIntvariable - Explicit narrowing:
InttoInt32requires conversion function withMayberesult
JSON Serialization
Attributes are serialized with omission of empty/None fields for compactness:
// Minimal (all defaults)
{ "Reference": { "fqname": "morphir/SDK:basics#int" } }
// With source location only
{
"Reference": {
"attributes": { "source": { "file": "src/Domain.elm", "line": 42, "col": 5 } },
"fqname": "morphir/SDK:basics#int"
}
}
// With numeric constraint
{
"Reference": {
"attributes": {
"constraints": {
"numeric": { "Signed": { "bits": 32 } }
}
},
"fqname": "morphir/SDK:int#int32"
}
}
// With bounded constraint (JSON Schema style)
{
"Reference": {
"attributes": {
"constraints": {
"numeric": { "Bounded": { "min": 0, "max": 100 } }
}
},
"fqname": "morphir/SDK:basics#int"
}
}
Migration from v3
Backward Compatibility
- Decoders accept
Type(a)format (v3) and convert toTypewithTypeAttributes.empty - The
attributesfield is optional in JSON; missing = empty
Forward Path
- Phase 1: Add attributes types, keep generic parameter
- Phase 2: Default generic parameter to
TypeAttributes/ValueAttributes - Phase 3: Remove generic parameter, use concrete attribute types
Boundary Extensions
For functions that cross component boundaries (Wasm imports/exports), the frontend inserts boundary metadata into the extensions field. This enables backends to generate appropriate ABI code.
Well-Known Boundary Extension Keys
| Extension FQName | Purpose |
|---|---|
morphir/wasm:boundary#export | Mark function as Wasm export |
morphir/wasm:boundary#import | Mark function as Wasm import |
morphir/wasm:boundary#abi-signature | Computed Canonical ABI signature |
morphir/wasm:boundary#memory-layouts | Memory layouts for complex types |
Export Example
- Gleam
- Scala 3
- Python
- Java 21+
// Source (with attribute)
@wasm_export
pub fn calculate_premium(policy: Policy, risk: Risk) -> Premium {
// ...
}
// Source (with annotation)
@wasmExport
def calculatePremium(policy: Policy, risk: Risk): Premium =
// ...
# Source (with decorator)
@wasm_export
def calculate_premium(policy: Policy, risk: Risk) -> Premium:
...
// Source (with annotation)
@WasmExport
public Premium calculatePremium(Policy policy, Risk risk) {
// ...
}
IR representation:
{
"ValueDefinition": {
"attributes": {
"extensions": {
"morphir/wasm:boundary#export": {
"Literal": { "literal": { "BoolLiteral": { "value": true } } }
},
"morphir/wasm:boundary#abi-signature": {
"Record": {
"fields": {
"params": { "List": { "items": [
{ "Record": { "fields": { "name": "policy_ptr", "type": "i32" } } },
{ "Record": { "fields": { "name": "risk_ptr", "type": "i32" } } }
] } },
"results": { "List": { "items": [
{ "Record": { "fields": { "type": "i32" } } }
] } }
}
}
}
}
},
"body": { "..." : "..." }
}
}
Import Example
- Gleam
- Scala 3
- Python
- Java 21+
// Source (with attribute)
@wasm_import("pricing-engine")
pub fn get_base_rate(product_code: ProductCode) -> Rate
// Source (with annotation)
@wasmImport("pricing-engine")
def getBaseRate(productCode: ProductCode): Rate
# Source (with decorator)
@wasm_import("pricing-engine")
def get_base_rate(product_code: ProductCode) -> Rate:
...
// Source (with annotation)
@WasmImport("pricing-engine")
public native Rate getBaseRate(ProductCode productCode);
IR representation:
{
"ValueDefinition": {
"attributes": {
"extensions": {
"morphir/wasm:boundary#import": {
"Literal": { "literal": { "StringLiteral": { "value": "pricing-engine" } } }
}
}
},
"body": { "..." : "..." }
}
}
Boundary Validation Rules
Functions marked as boundary functions are validated:
- No type variables — generics cannot cross boundaries
- No extensible records — row polymorphism not allowed
- All types must be boundary-compatible — must map to Canonical ABI
- Arbitrary-precision Int handling — configurable (error/warn/default to i64)
Validation behavior is configured in morphir.toml:
[wasm.boundary]
arbitrary_int = "error" # "error" | "warn" | "i64" | "i32"
arbitrary_float = "f64" # "error" | "warn" | "f64"
Open Questions
-
Constraint inference: Should frontends infer constraints from type aliases?
-
Constraint composition: How do constraints combine when multiple apply?
-
Custom constraint validation: How are
CustomConstraintpredicates evaluated? -
Boundary ABI storage: Should computed ABI signatures be:
- Stored in IR extensions (current approach)
- Computed on-demand by backends
- Stored in a separate artifact (
.abi.json)