Skip to the content.

Attached Properties — Design

Tracking item 5.1 in current-backlog.md. Captures the design before implementation so the trade-offs are explicit.

0. The reframing

Initial sketches treated “attached property” as a separate concept with its own registry, its own value store, and its own access methods. Working through the design with the WPF parallel made it clear that this is the wrong split — it’s an artifact of how XAML talks about it, not how the runtime needs to handle it.

WPF’s trick: every DependencyProperty is universally identifiable by (ownerType, name), and any DependencyObject can hold a value for any DP. The distinction between Register and RegisterAttached exists mostly for XAML readers and for some default-value semantics; the underlying storage and notification machinery is one set of mechanics.

The practical motivation in WPF is patterns like setting TextBlock.FontSize="14" on a <Border>: FontSize is registered on TextBlock (not on Border or any ancestor), but the Border holds the value and — because FontSize is Inherits — descendant TextBlocks pick it up via the tree walk. We want the same.

This design unifies the mechanics. Property identity is (owner, name), stored under a composite key on every Model. Two access surfaces — implicit-owner (existing API, walks the target’s class hierarchy) and explicit-owner (new overload). Both flow through the same EVD machinery.

1. Storage and identity

1.1 PropertyDescriptor gains an owner field

export class PropertyDescriptor {
    private name: string;
    private propertyClass: Function;          // NEW — the class that registered this property
    private own: PropertyMetadata;
    private parent_descriptor: PropertyDescriptor | undefined;

    constructor(owner: Function, name: string, own: PropertyMetadata, parent_descriptor?: PropertyDescriptor) {
        this.propertyClass = owner;
        this.name = name;
        // ...
    }

    public get Owner(): Function { return this.propertyClass; }
    public get Name(): string { return this.name; }
    // ... rest unchanged
}

The owner is the class that called RegisterProperty. For metadata overrides registered against a derived class via OverrideMetadata, the override descriptor’s Owner is the derived class — its parent reference still points to the original. So Owner tracks where the metadata lives, while the property’s identity is determined by walking the parent chain to the root descriptor.

For composite-key composition we use the root descriptor’s owner (the class that first registered the property). A small helper:

public get RootOwner(): Function {
    return this.parent_descriptor?.RootOwner ?? this.propertyClass;
}

1.2 Composite storage key — uniform for every property

// In Model
private static compose_key(owner: Function, property: string): string {
    return `${owner.name}.${property}`;
}

Every value lives on property_values: Map<string, EVD> under ${descriptor.RootOwner.name}.${name}. Regular properties and attached properties use the same composite key — the only difference is whether the caller specified the owner or let the implicit walk find it.

Collision risk: a regular property named 'Grid.Row' would collide with attached Grid.Row. Mitigation: reject . in registration time. Cheap, documented.

1.3 One descriptor registry

private static property_bags: WeakMap<Function, Map<string, PropertyDescriptor>>;

Already exists. No second parallel registry. Lookup has two entry points:

// Implicit owner — walks the target's class hierarchy looking for the
// first ancestor that registered `property`. Used by the no-owner
// overloads of set/get/etc.
protected static find_descriptor(klass: Function, property: string): PropertyDescriptor | undefined;

// Explicit owner — walks the owner's chain (so subclass overrides win)
// looking for `property` in the owner's bag. Used by the explicit-owner
// overloads.
protected static find_descriptor_on(owner: Function, property: string): PropertyDescriptor | undefined;

Both walk via Object.getPrototypeOf over the registered bags; only the starting class differs.

2. API — two access surfaces

Each accessor gains a second overload that takes an explicit owner. Internally both forms resolve to the same (descriptor, composite_key) pair and dispatch through a shared helper.

// Single registration entry point. Pure synonym alias for clarity at
// declaration sites (see §4).
public static RegisterProperty(
    owner: Function,
    property: string,
    default_value: any,
    meta_data: MetaData,
    coerce_value?: CoerceValue,
): void;

// Implicit owner — current public API, unchanged behavior.
public set_property_value(property: string, value: any): void;
public get_property_value(property: string): any;
public ClearValue(property: string): void;
public GetValueSource(property: string): PropertyValueSource;
public AddPropertyChangedListener(property: string, callback: PropertyChangeCallback): void;
public RemovePropertyChangedListener(property: string, callback: PropertyChangeCallback): void;

// Explicit owner — for cross-class / "attached" usage.
public set_property_value(owner: Function, property: string, value: any): void;
public get_property_value(owner: Function, property: string): any;
public ClearValue(owner: Function, property: string): void;
public GetValueSource(owner: Function, property: string): PropertyValueSource;
public AddPropertyChangedListener(owner: Function, property: string, callback: PropertyChangeCallback): void;
public RemovePropertyChangedListener(owner: Function, property: string, callback: PropertyChangeCallback): void;

2.1 Shared core

private set_via_descriptor(descriptor: PropertyDescriptor, value: any): void {
    const key = Model.compose_key(descriptor.RootOwner, descriptor.Name);
    let evd = this.property_values.get(key);
    if (evd === undefined) {
        const coerce = descriptor.CoerceValue;
        if (coerce !== undefined) value = coerce(this, value);
        evd = this.new_effective_value(descriptor);
        this.property_values.set(key, evd);
    }
    evd.value = value;
}

Both set_property_value overloads boil down to “find the descriptor, call set_via_descriptor”. Same shape for get/clear/source/listeners — one shared body, two entry points.

3. Usage examples

3.1 Regular case (no syntactic change)

class Box extends PanelBase {
    static { Model.RegisterProperty(Box, 'width', 0, MetaData.Measure | MetaData.Arrange); }
}

const b = new Box();
b.set_property_value('width', 100);   // implicit owner: walks Box's chain, finds Box.width
b.get_property_value('width');         // 100

3.2 Cross-class / attached case

class TextBlock extends Model {
    static {
        Model.RegisterProperty(TextBlock, 'fontSize', 12, MetaData.Inherits | MetaData.Measure);
        Model.RegisterProperty(TextBlock, 'foreground', 'black', MetaData.Inherits | MetaData.Render);
    }
}

class Border extends Single {
    // Border doesn't register fontSize or foreground; doesn't extend TextBlock.
}

const wrapper = new Border();
wrapper.set_property_value(TextBlock, 'fontSize', 14);   // explicit owner
wrapper.set_property_value(TextBlock, 'foreground', 'red');

// Inside the wrapper's subtree, every TextBlock sees fontSize=14, foreground='red'
// because both are Inherits-flagged. The values "live" on the Border but cascade
// down via the 1.5 inheritance machinery — which already works on composite keys.

3.3 WPF-style Grid.Row attached property

class Grid extends PanelBase {
    static {
        Model.RegisterAttachedProperty(Grid, 'Row',    0, MetaData.Arrange | MetaData.Measure);
        Model.RegisterAttachedProperty(Grid, 'Column', 0, MetaData.Arrange | MetaData.Measure);
    }

    // Static helpers (convention only):
    public static GetRow(target: Model): number { return target.get_property_value(Grid, 'Row') as number; }
    public static SetRow(target: Model, v: number): void { target.set_property_value(Grid, 'Row', v); }
}

const button = new Button();
Grid.SetRow(button, 2);
// Or directly:
button.set_property_value(Grid, 'Row', 2);

4. RegisterAttachedProperty — sugar synonym

Kept for readability at declaration sites — RegisterAttachedProperty(Grid, 'Row', ...) signals intent more clearly than RegisterProperty(Grid, 'Row', ...) when the property is meant for cross-class use. It’s a pure alias:

public static RegisterAttachedProperty(
    owner: Function,
    property: string,
    default_value: any,
    meta_data: MetaData,
    coerce_value?: CoerceValue,
): void {
    Model.RegisterProperty(owner, property, default_value, meta_data, coerce_value);
}

No runtime distinction. Either function produces the same descriptor in the same bag.

5. What carries over for free from existing machinery

Because all values live in property_values: Map<string, EVD> under composite keys, and the EVD type already carries property_descriptor, everything below the lookup layer works without further change:

6. The one subtlety in cascading

collect_inheritable_properties(klass) enumerates inheritable descriptors on the target’s own class hierarchy. For cross-class inherited properties (like TextBlock.foreground set on a Border), the target’s class doesn’t know about foreground, so the helper skips it.

That’s correct behavior for two reasons:

  1. Initiation — when a Border is attached/detached, it shouldn’t initiate cache fills for properties no ancestor in its class hierarchy declared. The implicit-owner walk wouldn’t find them.
  2. Reception — the descendant still receives cascade updates via propagate_inheritance_for(composite_key) — which is dispatched purely on the key, regardless of whether the receiving class “knows” the property. So when border.set_property_value(TextBlock, 'foreground', 'red') happens, the cascade reaches every descendant whose tree-walk would have found 'TextBlock.foreground' set on the border.

The subtree refresh on Attach is symmetric — it walks the target’s own inheritable properties because cross-class values stored on ancestors are reached by descendants via the cascade triggered when the ancestor was set, not via a periodic enumeration.

7. Implementation cost

Phase 1 — refactor + unified API

Roughly 120–150 lines of production code. Existing 103 tests should keep passing because the implicit-owner path is unchanged.

Phase 1 tests (new)

Phase 2 — optional sugar

8. Open questions deferred for later (not blocking)

Tracked in current-backlog.md § 15:

9. Decisions captured

Question Decision
Storage layout Composite key ${descriptor.RootOwner.name}.${name} on the existing property_values map — uniform for every property.
Registry Single property_bags registry. Lookup has two entry points (find_descriptor walks target hierarchy; find_descriptor_on walks owner hierarchy).
API shape Each accessor gains a second overload that takes an explicit owner. The no-owner overload (existing public API) is unchanged.
RegisterAttachedProperty Kept as a synonym alias for RegisterProperty — readability sugar at declaration sites, no runtime difference.
Descriptor-lookup refactor (option c in older revision) Mandatory — descriptors must carry their owner so set methods can compose the composite key.
Inheritance for cross-class properties Works out-of-the-box. The 1.5 cascade is key-based; composite keys flow through the existing walk/refresh/propagate machinery unchanged.
Listener identification Both implicit-owner and explicit-owner forms supported via the same overload pattern.
Property name . collision Rejected at registration time.