Skip to content

RFC: Tuple queries with first() / single() / trySingle() to eliminate optional-chaining noise #164

@lamaster

Description

@lamaster

Problem

Today, common reads look like this:

const x = world.queryFirst(PositionTrait)?.get(PositionTrait)?.x ?? 0

This double optionality (queryFirst may return no entity, and get may return no trait) forces ?.?. throughout code—even where components are required by design. It hurts readability, complicates error handling, and makes bugs easier to hide. The README shows selecting traits for updateEach, but there is no general way to iterate entities + guaranteed component tuples for ad‑hoc logic without per-access entity.get(...). See the README’s tuple-like updateEach example for context.

Reference: Koota README — Query and update data.


Proposal

Introduce tuple queries that only iterate entities which have all requested traits and yield tuples with those traits. Add clear single-result helpers:

  • first() → returns the first tuple or undefined
  • single() → returns the single tuple; throws if 0 or >1
  • trySingle() → returns { ok: true, value } | { ok: false, error }

Examples — how it should look

// Iterate only entities that have both Position and Velocity.
// Direct, mutation-ready access without ?.?.?.
for (const [entity, position, velocity] of world.queryTuple(Position, Velocity)) {
  position.x += velocity.dx * dt
  position.y += velocity.dy * dt
}

// Expect exactly one Player with Position:
const [playerEntity, playerPosition] = world.queryTuple(Position, Player).single()

// Optional single result without exceptions:
const maybe = world.queryTuple(Position, Player).trySingle()
if (maybe.ok) {
  const [entity, pos] = maybe.value
  // ...
}

// Get the first rocket if any:
const firstRocket = world.queryTuple(RocketTag, Position).first()
if (firstRocket) {
  const [rocket, position] = firstRocket
  // ...
}

Prior art (authoritative references)


Performance considerations

  • Zero extra lookups: selecting traits should be compiled into the query so we do not call entity.get repeatedly for each trait per entity. This mirrors the README’s updateEach(([a, b]) => { ... }) path.
  • No per-entity allocations: yielding tuples should avoid allocating new wrapper objects per iteration. Views/references or reusing ephemeral tuple containers are preferable.
  • Iteration stability: document the iteration order and behavior when structural changes (add/remove traits) occur during iteration.
  • single() complexity: single() must detect “more than one” efficiently. Scanning until a second match is acceptable; document this.

Edge cases to define

  • Structural changes during iteration: fail-fast vs snapshot semantics.
  • Type-level mutability: whether tuple items are read-only vs read-write should align with Koota’s current mutation model.
  • Composition with query modifiers: tuple queries should compose with future filters (With, Without, Changed) if/when a richer DSL lands (see Roadmap).

Related Koota discussion

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions