Skip to main content
For the complete documentation index optimized for AI agents, see llms.txt.
Up to this point every example has ended with .valueMap([...]) to ship a few properties back to the caller. This page covers the full set of terminal projections: how to shape the output, compute derived values, and aggregate the stream. A terminal step (.count, .values, .project, …) ends the chain and produces a result rather than another stream. TypeScript and Rust block further chaining at compile time; Go reports it at serialization (Validate, MarshalRequest, or Client.Exec).

Scalar terminals: .count, .exists, .id, .label

The smallest terminals collapse the whole stream into a single value.
import { g, readBatch, SourcePredicate } from "@helix-db/helix-db";

const aliceStats = readBatch()
  .varAs("post_count", g().nWithLabel("Post").count())
  .varAs(
    "alice_exists",
    g().nWhere(SourcePredicate.eq("username", "alice")).exists(),
  )
  .returning(["post_count", "alice_exists"]);
The four scalar terminals:
StepReturns
.count()Number of items in the stream.
.exists()true if the stream is non-empty, false otherwise.
.id()Stream of $id values, one per item.
.label()Stream of $label values, one per item.
.id() and .label() are usually most useful followed by another binding via .varAs(...), or as input to .project(...).

.values(...) and .valueMap(...)

When you want a few properties from each item:
  • .values([prop, ...]) — emits an array of arrays, one inner array per item, with the properties in the requested order. No keys, only positional values.
  • .valueMap([prop, ...]) — emits an array of objects keyed by property name. Pass null (or omit the argument) for “every property”.
valueMap is the default choice for service-facing routes; values is handy when the caller does its own positional decoding.
import { g, readBatch } from "@helix-db/helix-db";

const usersForAdminPanel = readBatch()
  .varAs(
    "rows",
    g()
      .nWithLabel("User")
      .valueMap(["$id", "$label", "username", "tier", "createdAt"]),
  )
  .returning(["rows"]);
$id, $label, $from, $to, $distance, and $score are virtual fields: they are always available even though they are not declared on your schema. $from/$to appear on edge streams; $distance/$score appear on ranked vector / text hits. Filtered values(...) and valueMap(...) also accept dotted paths into object properties, such as metadata.externalID. valueMap(null) returns stored top-level properties as-is and does not flatten nested objects. When you request a dotted path explicitly, the returned object is keyed by the requested source string unless you rename it with project(...).
g().nWithLabel("User").valueMap(["$id", "metadata.externalID"]);
g().n_with_label("User")
    .value_map(Some(vec!["$id", "metadata.externalID"]));
helix.G().NWithLabel("User").ValueMap("$id", "metadata.externalID")

.project(...) with renames and expressions

project([...]) is the most flexible terminal — it accepts a list of items, each one either:
  • PropertyProjection.new(prop) — emit prop under its own name.
  • PropertyProjection.renamed(source, alias) — emit source under a different key.
  • ExprProjection.new(alias, expr) — emit a computed expression under alias.
Use it when you want to flatten ids out from under $id, return a different name than the schema’s, or compute a derived field. PropertyProjection and Expr.prop(...) use the same property lookup rules as filters. This means nested object fields can be projected and renamed:
g().nWithLabel("User").project([
  PropertyProjection.renamed("metadata.externalID", "external_id"),
]);
g().n_with_label("User").project(vec![
    PropertyProjection::renamed("metadata.externalID", "external_id"),
]);
helix.G().NWithLabel("User").Project(
	helix.ProjectPropAs("metadata.externalID", "external_id"),
)
Dotted paths are also valid in fallback ordering, for example .orderBy("metadata.score", Order.Desc), but they are not backed by secondary indexes in V1. A typical project call mixes plain property pulls with one or two renames:
import { g, PropertyProjection, readBatch } from "@helix-db/helix-db";

const userCards = readBatch()
  .varAs(
    "cards",
    g()
      .nWithLabel("User")
      .project([
        PropertyProjection.renamed("$id", "id"),
        PropertyProjection.new("username"),
        PropertyProjection.new("tier"),
      ]),
  )
  .returning(["cards"]);
Each entry in Project is a bare object with source / alias (for PropertyProjection) or alias / expr (for ExprProjection). There is no enum tag around them — the Projection enum is untagged on the wire.

Expressions for ExprProjection

Expr is a small arithmetic-and-conditionals algebra:
  • Sources: Expr.prop(name), Expr.val(value), Expr.id(), Expr.timestamp(), Expr.datetime(), Expr.param(name).
  • Arithmetic: .add(other), .sub(other), .mul(other), .div(other), .modulo(other), .neg().
  • Branching: Expr.case([[predicate, expr], ...], elseExpr).
A small derived field — fall back to "unknown" when the tier property is null:
import {
  Expr,
  ExprProjection,
  g,
  Predicate,
  PropertyProjection,
  readBatch,
} from "@helix-db/helix-db";

const userCardsWithFallback = readBatch()
  .varAs(
    "cards",
    g()
      .nWithLabel("User")
      .project([
        PropertyProjection.renamed("$id", "id"),
        PropertyProjection.new("username"),
        ExprProjection.new(
          "tier",
          Expr.case(
            [[Predicate.isNotNull("tier"), Expr.prop("tier")]],
            Expr.val("unknown"),
          ),
        ),
      ]),
  )
  .returning(["cards"]);
For time-relative comparisons against “now” (e.g. “rows newer than 30 days”), use Expr.timestamp() (server epoch millis) or Expr.datetime() (typed datetime) on one side of a Predicate.compare(left, CompareOp.*, right). Arithmetic on Expr returns another Expr, so you can chain Expr.timestamp().sub(Expr.val(30 * 86_400 * 1000)) to compute a cut-off server-side.

Edge property output

For edge streams, .edgeProperties() emits each edge as an object with all of its properties plus the virtual $id, $label, $from, $to, $distance, and $score fields when those fields are present on the current ranked edge stream.
import { g, NodeRef, readBatch, SourcePredicate } from "@helix-db/helix-db";

const aliceFollowsEdges = readBatch()
  .varAs("user", g().nWhere(SourcePredicate.eq("username", "alice")))
  .varAs(
    "edges",
    g().n(NodeRef.var("user")).outE("FOLLOWS").edgeProperties(),
  )
  .returning(["edges"]);

Aggregations: .group, .groupCount, .aggregateBy

For grouped counts and aggregate statistics:
  • .group(prop) — bucket the stream by prop, emit {groupValue: [items...]}.
  • .groupCount(prop) — bucket and count, emit {groupValue: count}.
  • .aggregateBy(AggregateFunction.*, prop) — apply a reducer to one property: Count, Sum, Min, Max, Mean.
import { AggregateFunction, g, readBatch } from "@helix-db/helix-db";

const tierBreakdown = readBatch()
  .varAs("by_tier", g().nWithLabel("User").groupCount("tier"))
  .varAs("avg_post_age", g().nWithLabel("Post").aggregateBy(AggregateFunction.Mean, "createdAt"))
  .returning(["by_tier", "avg_post_age"]);
AggregateFunction values are serialized as strings: "Count", "Sum", "Min", "Max", "Mean".

Next Steps

Mutations

writeBatch, addN, addE, setProperty, drop — and using results inside the same batch.

Vector and text search

vectorSearchNodes, textSearchNodes, projecting $distance, follow-on traversal.