Skip to content

Rec — record utilities

Plain JavaScript objects used as maps — Record<string, A> — are one of the most common data structures in any TypeScript codebase. Rec is a small collection of utilities for working with them in pipelines: data-last, curried, and returning Maybe wherever a key might not exist.

Rec.lookup retrieves a value by key and returns Maybe to make the absence explicit:

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

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

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

This composes naturally with Maybe operations:

pipe(
	config,
	Rec.lookup("timeout"), // Maybe<string>
	Maybe.chain(parseNumber), // Maybe<number>
	Maybe.getOrElse(() => 30_000),
);

Rec.lookup always returns Maybe — if you need the raw undefined for interop with code that expects it, plain obj[key] is still there.

map is the most common operation — most record transformations are just “apply this function to every value”. Reach for mapWithKey when the key matters in the transformation.

map transforms every value in a record, preserving keys:

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

mapWithKey receives both key and value:

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

filter keeps entries where the predicate passes:

pipe({ a: 1, b: 2, c: 3 }, Rec.filter((n) => n > 1));
// { b: 2, c: 3 }

filterWithKey receives both key and value:

pipe(
	{ a: 1, b: 0, c: 3 },
	Rec.filterWithKey((key, val) => key !== "a" && val > 0),
); // { c: 3 }

pick returns a new record with only the specified keys:

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

omit returns a new record with the specified keys removed:

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

Both are type-safe: pick returns Pick<A, K> and omit returns Omit<A, K>, so the resulting type reflects exactly which keys are present.

merge combines two records. Keys in the second record take precedence over the first:

pipe(
	{ a: 1, b: 2 },
	Rec.merge({ b: 99, c: 3 }),
); // { a: 1, b: 99, c: 3 }
const rec = { x: 10, y: 20 };

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

fromEntries is the inverse — builds a record from key-value pairs:

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

entries and fromEntries pair well when you want to transform both keys and values by converting to entries, mapping, and converting back:

pipe(
	{ firstName: "Alice", lastName: "Smith" },
	Rec.entries,
	(entries) => entries.map(([k, v]) => [k.toUpperCase(), v] as const),
	Rec.fromEntries,
); // { FIRSTNAME: "Alice", LASTNAME: "Smith" }
Rec.isEmpty({ a: 1 }); // false
Rec.isEmpty({}); // true
Rec.size({ a: 1, b: 2 }); // 2

Because all Rec functions are curried and data-last, they chain naturally:

const result = pipe(
	rawConfig,
	Rec.filter((v) => v !== null),
	Rec.mapWithKey((key, val) => `${key}: ${val}`),
	Rec.omit("debug", "internal"),
);

Each step produces a new record — no mutation, no intermediate variables.

Use Rec when:

  • You’re transforming or filtering a Record<string, A> and want the step to compose in pipe
  • You need Maybe-returning key lookup instead of undefined
  • You’re picking or omitting keys and want the resulting type to reflect exactly what’s present

Keep using plain object operations when:

  • The operation is a one-liner in a function body where obj[key] or spread is already clear
  • You don’t need the result to compose in a pipeline