Skip to content

Feature request: Record Spread for Type-Safe copyWith-Style Updates #4676

@Hu-Wentao

Description

@Hu-Wentao

Record Spread with Safe Defaults and Controlled Overrides

Summary

This proposal introduces a record spread operator ... inside record literals to support:

  1. Concise, type-safe copyWith-style updates
  2. Composability by embedding records into larger records

While enabling both use cases, the design enforces safe defaults:

  • Spread operations never implicitly override fields
  • Field replacement must be explicit
  • The behavior is predictable and stable under API evolution

Design Goals

  • Preserve immutability ergonomics (copyWith-style updates)
  • Enable record composition (embedding smaller records into larger ones)
  • Avoid implicit field overriding
  • Maintain strong compile-time guarantees
  • Keep syntax minimal and consistent

Core Semantics

1. Spread = structural expansion only

(...record)

Expands all fields of record into the new record.


2. Multiple spreads concatenate (no implicit override)

(...record1, ...record2)
  • Positional fields: concatenated in order
  • Named fields: must not conflict
// Compile-time error if both define `foo`
(...r1, ...r2)

➡️ No silent overriding.


3. Named field override (explicit by position in literal)

(...record, foo: newValue)
  • Allowed only if foo already exists
  • Replaces the previously introduced field
// Compile-time error
(...record, unknownField: 123)

4. Positional field override via integer index

(...record, 1: newValue)
  • Replaces positional field at index 1
  • Only allowed if that field was introduced by a spread
// Compile-time error
(1: newValue) // no prior spread

5. No $n positional syntax

Positional fields are referenced using integer indices, not $1, $2.

Rationale:

  • Avoids conflict with valid named fields like $2
  • Keeps syntax aligned with positional semantics

Examples

CopyWith-style update

final state = (isLoading: false, error: null, isSuccess: false);

final newState = (
  ...state,
  isLoading: true,
);

Positional update

final point = (10, 20);

final moved = (
  ...point,
  1: 50,
);

Record composition (embedding)

final point = (x: 10, y: 20);

final colored = (
  ...point,
  color: "red",
);

Combining records

final a = (1, 2);
final b = (3, 4);

final combined = (...a, ...b); // (1, 2, 3, 4)

Conflict detection

final a = (foo: 1);
final b = (foo: 2);

(...a, ...b); // Compile-time error

Rationale

1. Safe by default

  • No implicit overriding
  • No accidental field shadowing
  • Fail-fast behavior

2. Supports both key use cases

Use case Supported
copyWith-style updates
record embedding
record concatenation
implicit merging

3. Avoids API evolution hazards

Unlike argument-list spreading:

  • No cross-boundary implicit binding
  • No silent behavior changes when APIs evolve
  • Field usage remains explicit

4. Minimal mental model

  • ... always means expand
  • Overriding is explicit and constrained
  • No context-dependent semantics

Alternatives Considered

Implicit override (rejected)

(...record, foo: newValue)

where foo may or may not exist.

❌ Problem:

  • Silent bugs
  • Harder to reason about
  • Weakens type guarantees

Explicit override syntax (:=, ...=)

(...record, foo:= newValue)
(...record, ...=otherRecord)

❌ Rejected because:

  • Adds new syntax surface
  • Increases complexity
  • Not necessary if safe constraints exist

Acknowledgement

Parts of this proposal were inspired by community discussion, including suggestions around:

@lrhn

  • Using integer indices for positional field updates
  • Treating multiple spreads as concatenation rather than override
  • Avoiding $n syntax for positional access

These contributions helped shape a design that balances composability and safety.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problems

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions