Smooth Operators: Declarative Sequence Transformations in TypeScript

Sequence Operators
In this post, we will refer to these higher-order array methods as sequence operators, since they operate over sequences of values in a declarative way.
You can find the accompanying example code on GitHub: Declarative Sequence Transformations
A sequence operator is a higher-order function that takes a sequence, like an array or list, applies a function to each element, and returns a new value or sequence, enabling declarative, step-by-step data transformations. Many modern languages provide their own versions of these operators, such as LINQ in C# or the Stream API in Java. In this post, we will focus on several core sequence operators using TypeScript as our example.
Throughout this article, we will make use of a Note type. Assume we already have data coming from some data source, and we will refer to it simply as notes. The aim here is to keep the article abstract and focused on transformations rather than mock data, so you will often see a variable called notes in the examples.
Type Reference
note.types.ts
interface Note {
id: string
title: string
tags: string[]
sections: Section[]
}
interface Section {
heading: string
links: string[]
references: string[]
}
map(T => R)
map transforms each element in a sequence using a function and returns a new sequence of the same length. Each element of the sequence is passed to the mapping function.
We can apply a transformation to each element in the notes sequence to produce cards by extracting data from each note and reshaping it into a new type.
interface Card {
id: string
title: string
sectionCount: number
tagCount: number
}
const cards: Card[] =
notes.map<Card>(note => ({
id: note.id,
title: note.title,
sectionCount: note.sections.length,
tagCount: note.tags.length
}))
The result is a new sequence where each Note has been transformed into a Card. The original notes array is left untouched, and a new cards array is produced with the same number of elements, but a different data shape.
filter(T => boolean)
filter evaluates each element in a sequence using a predicate function, that is, a function that returns a boolean. filter does not transform items in the list. Instead, it pares down the sequence such that the resulting sequence only contains elements for which the predicate evaluates to true.
We can apply a predicate to each element in the notes sequence to produce a new sequence of only those notes whose tags include 'functional'.
const functionalNotes: Note[] =
notes.filter(note => note.tags.includes('functional'))
We can also select out any notes that have sections.
const isNotEmpty = <T>(arr: T[]) => arr.length > 0
const notesWithContent: Note[] =
notes.filter(note => isNotEmpty(note.sections))
In the examples above, we pare down the original notes sequence based on the provided predicates. In the first example, we only keep notes that include a tag equal to "functional". In the second example, we only keep notes whose sections sequence is not empty.
flatMap(T => R[])
flatMap is very similar to map in that it applies a transformation to a sequence. The difference is that the provided function returns another sequence, which is then flattened down into a single sequence of elements.
We can use flatMap to grab all of the tags within each note and flatten those tags into a single sequence, without touching the original array.
const allTags: string[] =
notes.flatMap<string>(note => note.tags)
We can use the same concept to extract all links across all notes. Feel free to jump to the Type Reference section as a refresher on the shape of the Note type.
const allSections: Section[] =
notes.flatMap<Section>(note => note.sections)
const allLinks: string[] =
allSections.flatMap<string>(section => section.links)
Now, let’s extend the example beyond simple extraction of strings and indulge in transforming notes to provide us with an entirely new sequence of link artifacts.
interface LinkArtifact {
noteId: string
heading: string
url: string
}
const allLinkArtifacts: LinkArtifact[] =
notes.flatMap(note =>
note.sections.flatMap(section =>
section.links.map(link => ({
noteId: note.id,
heading: section.heading,
url: link
}))
)
)
In the examples above, we are transforming a sequence of notes into various data shapes by extracting nested sequences, flattening them, extracting additional values, and then flattening again. In other words, we are applying a transformation over a sequence of sequences and flattening that down into a single sequence, conceptually: [[]] -> [].
Conceptually, flatMap can be hard to grasp. If it helps, focus on the simple example of extracting all tags from a sequence of notes and ending up with just a single sequence of tags in the end.
reduce((TState, TValue) => TState, TState)
reduce is a sequence operator that applies a function to each value of a sequence and accumulates the result into a single state value. On each pass, the current element from the sequence and the previous accumulator value are provided to the reducer function. The value returned becomes the next accumulator. reduce requires a seed, or initial value, to kick things off.
The following example is for explanation, as we could more easily get the count of all tags by first applying flatMap and then accessing the resulting length. However, counting all items in a sequence is a simple example to grasp conceptually, and thus a good introduction to reduce.
const allTags: string[] =
notes.flatMap(note => note.tags)
const tagCount: number =
allTags.reduce((count: number, _tag: string) => count + 1, 0)
In the example above, we omit the use of the tag itself because we only care about the total count. For each element in the sequence, we add 1 to the current count, which becomes the next accumulator value. This continues until the end of the sequence is reached. You can think of this process as recursive in nature, since each step depends on the result of the previous one.
Now, let’s imagine that our goal is to count all of the individual unique tags in our notes and end up with a data shape that uses a tag as a key and its count as the value.
type TagCounts = Record<string, number>
const allTags: string[] =
notes.flatMap(note => note.tags)
const counts: TagCounts =
allTags.reduce<TagCounts>((accumulator: TagCounts, tag: string) => {
const currentCount = accumulator[tag] ?? 0
accumulator[tag] = currentCount + 1
return accumulator
}, {})
In the example above, we first use flatMap to extract all tags from the notes sequence, producing a simple structure to work with, a single sequence of tags. We then reduce that sequence down into a single data structure, a record whose keys are tags and whose values represent how many times each tag appears in the sequence.
Finally, in the below example, we use reduce to accumulate multiple sequences into a single structured result. Rather than reducing to a number or a simple value, we are reducing the sequence of notes into a richer data shape that contains two sequences, one for links and one for references.
interface Artifacts {
links: string[]
references: string[]
}
const artifacts: Artifacts =
notes.reduce<Artifacts>((acc, note) => {
const links =
note.sections.flatMap(section => section.links)
const references =
note.sections.flatMap(section => section.references)
return {
links: acc.links.concat(links),
references: acc.references.concat(references)
}
}, { links: [], references: [] })
We start with a seed value that represents an empty result, an object with two empty sequences. On each pass, we take a single note and extract its links and references by flattening over its sections. Rather than mutating the accumulator, we return a new object that combines the previously accumulated values with the newly extracted ones using concat. Each step produces a new accumulator that becomes the input to the next pass. By the end of the sequence, reduce has produced a final Artifacts value containing every link and every reference across all notes, without ever pushing to an external list or mutating shared state. reduce really shines here because it lets us describe how to build up a complex result from a sequence in a declarative way. In this example, we construct two sequences in parallel through the composition of flatMap, concat, and reduce.
Building a sequence immutably
So far we have looked at map, filter, and flatMap as the building blocks for declarative transformations. We can combine those operators to build larger sequences without reaching for reduce, and without pushing into an external list.
Let’s say our goal is to build an artifacts sequence, but only for notes that include both the "functional" and "sequence" tags. From those notes, we want to extract all references and all links, then concatenate those results into a single list.
interface Artifact {
kind: 'link' | 'reference'
value: string
}
const hasTag = (tag: string) =>
(note: Note) =>
note.tags.includes(tag)
const isFunctional: (note: Note) => boolean =
hasTag('functional')
const isSequence: (note: Note) => boolean =
hasTag('sequence')
const isFunctionalSequenceNote = (note: Note): boolean =>
isFunctional(note) && isSequence(note)
const functionalSequenceNotes: Note[] =
notes.filter(isFunctionalSequenceNote)
const references: Artifact[] =
functionalSequenceNotes
.flatMap(note =>
note.sections.flatMap(section =>
section.references.map(reference => ({
kind: 'reference',
value: reference
}))
)
)
const links: Artifact[] =
functionalSequenceNotes
.flatMap(note =>
note.sections.flatMap(section =>
section.links.map(link => ({
kind: 'link',
value: link
}))
)
)
const artifacts: Artifact[] =
references.concat(links)
The above approach keeps each step focused and readable. filter narrows the input sequence down to only the notes we care about, flatMap flattens nested sequences like sections and links, and map transforms each extracted value into a consistent artifact shape. Finally, concat combines independently-built sequences into one larger sequence, all without mutating state or manually pushing into a list.
Conclusion
In this post, we explored a small but powerful set of sequence operators and how they can be composed to transform data in a declarative and immutable way. Using map, filter, flatMap, and reduce, we shaped, narrowed, flattened, and accumulated sequences without mutating state or relying on external loops and push operations. Each operator focuses on a single concern, and when combined, they form a clear and expressive pipeline for working with data.
The examples built on the same notes sequence, showing how small, intentional transformations can grow into more meaningful results, from simple projections to richer artifact structures. Rather than describing how to perform each step imperatively, we described what each step should produce, letting the sequence operators handle the mechanics.
These operators only scratch the surface of what is available when working with sequences in JavaScript. Functions like some and every help answer questions about a sequence, find and findIndex locate specific elements, sort and slice help reshape and reorder data, and groupBy opens the door to building structured views of a sequence. When combined with chaining and composition, these operators provide a rich vocabulary for expressing data transformations clearly and concisely.
Once you start thinking in terms of sequences and transformations, you may find fewer reasons to reach for mutable loops and temporary collections. Instead, you can build results step by step, directly from the data, in a way that is easier to read, reason about, and extend.
Building a sequence is about the journey and not a mutable destination.




