Skip to content

Rec — Record Utilities

Plain JavaScript objects serving as dictionaries — typed as Record<string, A> — are the most ubiquitous data structures in any TypeScript application. We use them for configurations, lookup tables, and serialized payloads.

However, working with records in functional pipelines introduces two common points of friction:

  1. They are data-first: Modifying objects natively forces us to write verbose, inline spreads inside our pipe chains: (obj) => ({ ...obj, key: value }).
  2. They are unsafe: Accessing a missing key via bracket notation (obj[key]) silently returns undefined at runtime, bypassing the type system and causing errors downstream.

Rec solves both issues. It provides a small, highly optimized collection of data-last, curried utilities designed to compose cleanly in pipelines, returning explicit Maybe values for safe, crash-free key lookups.


Rec.lookup retrieves the value associated with a key, wrapping it in a Maybe container to make key absence explicit in your types:

import { pipe } from "@nlozgachev/pipelined/composition";
import { Maybe } from "@nlozgachev/pipelined/core";
import { Rec } from "@nlozgachev/pipelined/data";

const settings = { theme: "dark", language: "en" };

pipe(settings, Rec.lookup("theme")); // Some("dark")
pipe(settings, Rec.lookup("font"));  // None (not undefined)

This integrates naturally with other pipelines:

const serverTimeout = pipe(
  configPayload,
  Rec.lookup("timeout"),
  Maybe.map((s) => Number(s)),
  Maybe.filter((n) => !isNaN(n)),
  Maybe.getOrElse(() => 30000), // Secure fallback
);

Rec.map transforms every value inside a record, returning a new record with the original keys preserved:

pipe({ a: 1, b: 2 }, Rec.map((n) => n * 10)); // { a: 10, b: 20 }

If the transformation requires the key as well as the value, Rec.mapWithKey passes both to your callback:

pipe({ a: 1, b: 2 }, Rec.mapWithKey((key, value) => `${key}_${value}`));
// { a: "a_1", b: "b_2" }

  • Rec.filter keeps only the entries whose values satisfy a predicate.
  • Rec.filterWithKey passes both the key and the value to the predicate:
// Keep only values greater than 1:
pipe({ a: 1, b: 2, c: 3 }, Rec.filter((n) => n > 1)); // { b: 2, c: 3 }

// Keep values where the key matches a specific prefix and value is non-zero:
pipe(
  { a: 1, b: 0, c: 3 },
  Rec.filterWithKey((key, value) => key !== "a" && value > 0),
); // { c: 3 }

  • Rec.pick returns a new record containing only the specified keys.
  • Rec.omit returns a new record with the specified keys removed.

Both utilities are fully type-safe. pick returns a precise Pick<A, K> type and omit returns a precise Omit<A, K> type, ensuring the compiler tracks exactly which properties survive the pipeline:

const baseProfile = { id: "123", name: "Alice", email: "alice@example.com" };

const summary = pipe(baseProfile, Rec.pick("id", "name")); // { id: "123", name: "Alice" }
const publicView = pipe(baseProfile, Rec.omit("email"));   // { id: "123", name: "Alice" }

Rec.merge combines two records, returning a fresh object. Keys present in the second record override those in the first record, behaving identically to standard object spreads:

pipe(
  { a: 1, b: 2 },
  Rec.merge({ b: 99, c: 3 }),
); // { a: 1, b: 99, c: 3 }

Rec provides utilities to extract arrays of keys, values, or entries:

const coordinates = { x: 10, y: 20 };

Rec.keys(coordinates);    // ["x", "y"]
Rec.values(coordinates);  // [10, 20]
Rec.entries(coordinates); // [["x", 10], ["y", 20]]

Rec.from.entries is the inverse constructor, building a record from an array of key-value pairs:

Rec.from.entries([["a", 1], ["b", 2]]); // { a: 1, b: 2 }

You can pair entries and from.entries to easily perform structural record mappings:

// Upper-casing all keys in a record:
const rawInput = { firstName: "Alice", lastName: "Smith" };

const parsed = pipe(
  rawInput,
  Rec.entries,
  (entries) => entries.map(([key, value]) => [key.toUpperCase(), value] as const),
  Rec.from.entries,
); // { FIRSTNAME: "Alice", LASTNAME: "Smith" }

Rec.is.empty({});         // true
Rec.is.empty({ a: 1 });   // false

Rec.size({ a: 1, b: 2 }); // 2

When a record contains values that wrap effectful computations, you may want to aggregate those individual effects into a single container holding the mapped record. Rec.traverse and Rec.sequence provide namespaces for traversing and sequencing records, short-circuiting on the first failure.

Rec.traverse.Maybe maps a Maybe-returning function over the values of a record. If every value produces a Some, it returns Some of the updated record. If any value maps to None, the entire operation returns None:

import { Maybe } from "@nlozgachev/pipelined/core";
import { Rec } from "@nlozgachev/pipelined/data";

const parseNum = (s: string) => s === "NaN" ? Maybe.make.none() : Maybe.make.some(Number(s));

pipe({ a: "1", b: "2" }, Rec.traverse.Maybe(parseNum)); // Some({ a: 1, b: 2 })
pipe({ a: "1", b: "NaN" }, Rec.traverse.Maybe(parseNum)); // None

Rec.sequence.Maybe takes a record where every value is already a Maybe and collapses it into a single Maybe of a record:

Rec.sequence.Maybe({ a: Maybe.make.some(1), b: Maybe.make.some(2) }); // Some({ a: 1, b: 2 })
Rec.sequence.Maybe({ a: Maybe.make.some(1), b: Maybe.make.none() }); // None

Similarly, Rec.traverse.Result and Rec.sequence.Result aggregate fallible computations:

import { Result } from "@nlozgachev/pipelined/core";

const validateStock = (quantity: number) =>
  quantity < 0 ? Result.make.err("Negative stock not allowed") : Result.make.ok(quantity);

pipe({ apples: 10, oranges: 5 }, Rec.traverse.Result(validateStock)); // Ok({ apples: 10, oranges: 5 })
pipe({ apples: 10, oranges: -1 }, Rec.traverse.Result(validateStock)); // Err("Negative stock not allowed")

Sometimes, you need compile-time assurance that a record contains at least one key-value pair. You can represent this with the branded type Rec.NonEmpty<A> and refine standard records using the type guard Rec.is.nonEmpty.

const userRecord = { admin: "Alice" };

if (Rec.is.nonEmpty(userRecord)) {
  // Inside this block, userRecord is typed as Rec.NonEmpty<string>
}

You can create a non-empty record directly using Rec.NonEmpty.singleton or parse a standard record using Rec.NonEmpty.from.Record:

const singletonRec = Rec.NonEmpty.singleton("main", 42); // Rec.NonEmpty<number>

const parsedRec = Rec.NonEmpty.from.Record(userRecord); // Some(Rec.NonEmpty<string>)
const emptyRec = Rec.NonEmpty.from.Record({});          // None

Operating on Rec.NonEmpty<A> ensures that operations which would normally return standard arrays (which could be empty) instead return guaranteed non-empty arrays (Arr.NonEmpty). For example, extracting keys, values, or entries from a non-empty record returns Arr.NonEmpty:

const users = Rec.NonEmpty.singleton("admin", "Alice");

Rec.NonEmpty.keys(users);    // Arr.NonEmpty<string> (e.g. ["admin"])
Rec.NonEmpty.values(users);  // Arr.NonEmpty<string> (e.g. ["Alice"])
Rec.NonEmpty.entries(users); // Arr.NonEmpty<readonly [string, string]> (e.g. [["admin", "Alice"]])

You can also reduce the values of a non-empty record without providing an initial accumulator seed, since there is guaranteed to be at least one value:

const scores = Rec.NonEmpty.singleton("math", 95);
pipe(scores, Rec.NonEmpty.reduce((a, b) => a + b)); // 95

Additionally, Rec.map and Rec.mapWithKey preserve the non-empty status of the record in their return types:

const doubled = Rec.map((n: number) => n * 2)(scores); // Rec.NonEmpty<number>

For more details on operating with guaranteed non-empty data structures, see the dedicated Non-Empty Collections Guide.


  • Operating inside pipelines: You are transforming, filtering, or merging records point-free inside pipe chains.
  • You require type-safe picks or omits: You want the compiler to statically track exactly which properties exist after keys are picked or omitted.
  • Safe key retrieval is required: You want to avoid accidental undefined runtime crashes by capturing key absence as a Maybe container.
  • The operation is a simple, local one-liner: Inside a narrow function body where standard dot-notation obj.key or spreads { ...obj } are already clear and require no composition.