DEV Community

Cover image for Laravel Collections Are Just Better Arrays. TypeScript Has 90% Too.
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Collections Are Just Better Arrays. TypeScript Has 90% Too.


A Laravel developer opens a TypeScript project for the first time and types out a familiar shape from muscle memory:

$report = collect($users)
    ->where('active', true)
    ->groupBy('country')
    ->map(fn ($group) => $group->count());
Enter fullscreen mode Exit fullscreen mode

Five chained calls. Reads like a sentence. The PHP dev now wants the same shape in TypeScript and starts the search the way every Laravel-to-Node refugee has: lodash. They open the docs, scroll for groupBy, and copy the example.

That search is six years out of date.

Modern TypeScript arrays already do most of what Collection does. The few that arrays don't do live one method call away in Object, or in a three-line helper. You write the chain on the array. No wrap step.

This is one of the bigger mindset shifts when moving from Laravel to TypeScript, and one of the easier ones to enjoy once it lands. PHP arrays are anaemic, so Laravel had to ship Collection. TypeScript arrays already have the methods on them. The reason Collection exists at all is that PHP's array_map/array_filter/array_reduce take their arguments in inconsistent order, return new arrays instead of chaining, and conflate associative arrays with lists. Collection papered over that. JavaScript never had the underlying problem, so the wrapper never had to exist.

Fifteen of the most-used Collection methods, what they map to in TypeScript, and the small handful that need a helper.

The Mapping Table

The boring shape first. Then we'll dig into the few interesting ones.

Laravel Collection TypeScript equivalent Notes
->map($fn) arr.map(fn) Identical semantics.
->filter($fn) arr.filter(fn) TS narrows the result type with type predicates.
->reduce($fn, $initial) arr.reduce(fn, initial) Same accumulator shape.
->each($fn) arr.forEach(fn) or for...of for...of is preferred for await.
->groupBy($key) Object.groupBy(arr, fn) ES2024. Returns Record<string, T[]>.
->where($key, $value) arr.filter(x => x[key] === value) Uses ===, so no implicit coercion.
->first($fn?) arr.find(fn) or arr[0] find returns undefined, not null.
->contains($v) arr.includes(v) or arr.some(fn) includes for value, some for predicate.
->pluck($key) arr.map(x => x[key]) One method shorter to write.
->keys() / ->values() Object.keys(o) / Object.values(o) Map has .keys() / .values() methods.
->sort() / ->sortBy($k) arr.toSorted(cmp) ES2023. Non-mutating.
->reverse() arr.toReversed() ES2023. Non-mutating.
->unique() [...new Set(arr)] Set for primitives, Map keyed on the field for objects.
->chunk($n) helper, or Array.from({...}) Three lines. Or pull from a util.
->partition($fn) helper, or reduce Three lines.
->mapWithKeys($fn) Object.fromEntries(arr.map(fn)) One nested call, no wrapper.
->tap($fn) helper Three lines.
->pipe($fn) fn(arr) The pipeline operator is still a proposal; for now, normal calls.

The rest of the post digs into the two methods that have new native answers (groupBy and unique/uniqueBy) and the small handful that legitimately don't have a one-liner.

Object.groupBy: The One That Used to Hurt

For years, groupBy was the entry-line on every "why I use lodash" blog post. Vanilla JS made you write the reduce by hand:

// Before ES2024
const byCountry = users.reduce<Record<string, User[]>>((acc, user) => {
  (acc[user.country] ??= []).push(user)
  return acc
}, {})
Enter fullscreen mode Exit fullscreen mode

Functional, but lumpy. You could feel the missing primitive. ES2024 fixed it. Object.groupBy is in the spec, in V8, in JavaScriptCore, in SpiderMonkey, and in Node.js since v21. It is shipping in every major runtime: Node 21+, Bun, Deno, Chrome 117+, Firefox 119+, Safari 17.4+. (See the MDN browser-compat table for the canonical version matrix.)

The same query, today:

const byCountry = Object.groupBy(users, (user) => user.country)
// Record<string, User[]>
Enter fullscreen mode Exit fullscreen mode

That is the entire reason you used to install lodash for collection-style work. It is a method on Object now.

There is a sibling, Map.groupBy, that returns a Map keyed on whatever the callback returns. Use it when the key is not a string. Object.groupBy coerces the key to a string; Map.groupBy keeps the original type. This matters when you are grouping by a date, a number, a tuple, or any object reference.

// Group orders by their delivery date (a Date, not a string)
const byDate = Map.groupBy(orders, (o) => o.deliveryDate)
// Map<Date, Order[]>
Enter fullscreen mode Exit fullscreen mode

The Laravel version of that query forces you to format the date into a string key first. The TS version does not.

Set, Map, and the unique() Story

->unique() on a Laravel collection is straightforward for primitives and a small puzzle for objects. The TS story is the same shape but the primitives are nicer.

For primitives, Set is the answer:

const tags = ['php', 'typescript', 'php', 'go', 'typescript']
const distinct = [...new Set(tags)]
// ['php', 'typescript', 'go']
Enter fullscreen mode Exit fullscreen mode

For objects, you want unique by some field, which Laravel writes as ->unique('email'). The TS version uses Map:

function uniqueBy<T, K>(arr: T[], key: (item: T) => K): T[] {
  return [...new Map(arr.map((item) => [key(item), item])).values()]
}

const distinctUsers = uniqueBy(users, (u) => u.email)
Enter fullscreen mode Exit fullscreen mode

That is more code than ->unique('email'), but you write it once and import it forever. The reason it is one line in Laravel is that Collection shipped with it. The reason TypeScript does not is that the standard library is small on purpose, and the language gives you Map and Set so you can build the helper in one line. So the unique-by-field helper above is the longest one in this post.

chunk, partition, tap: The Three-Liners

chunk splits an array into fixed-size groups.

function chunk<T>(arr: T[], size: number): T[][] {
  return Array.from(
    { length: Math.ceil(arr.length / size) },
    (_, i) => arr.slice(i * size, i * size + size),
  )
}

chunk([1, 2, 3, 4, 5, 6, 7], 3)
// [[1, 2, 3], [4, 5, 6], [7]]
Enter fullscreen mode Exit fullscreen mode

partition splits into two arrays based on a predicate. Collection's version returns a tuple of two collections; ours returns a tuple of two arrays.

function partition<T>(
  arr: T[],
  pred: (item: T) => boolean,
): [T[], T[]] {
  const pass: T[] = []
  const fail: T[] = []
  for (const item of arr) {
    (pred(item) ? pass : fail).push(item)
  }
  return [pass, fail]
}

const [active, inactive] = partition(users, (u) => u.active)
Enter fullscreen mode Exit fullscreen mode

tap runs a side effect mid-chain and returns the value untouched. The Laravel use case is ->tap(fn(...) => Log::info(...)). In TS, the chain breaks at any function call anyway, so tap is rarely needed. When it is:

function tap<T>(value: T, fn: (value: T) => void): T {
  fn(value)
  return value
}

const user = tap(await fetchUser(id), (u) => logger.info({ user: u.id }))
Enter fullscreen mode Exit fullscreen mode

Some teams pull these from a shared utils package; some inline them; some import from radash or lodash if the dependency is already there. Lodash is roughly 24 KB minified+gzipped (about 70 KB unminified) for the full build, so the cost is real if it is not already in your tree.

mapWithKeys: One Nested Call

Laravel's ->mapWithKeys() lets a closure return both the key and the value of the new collection. The TS equivalent is Object.fromEntries plus map:

const usersById = Object.fromEntries(
  users.map((u) => [u.id, u] as const),
)
// Record<string, User>
Enter fullscreen mode Exit fullscreen mode

The as const is the bit a PHP migrant misses on the first try. Without it, [u.id, u] is (number | User)[] and Object.fromEntries complains. With it, it is readonly [number, User], and the inferred record type is honest.

If you want a Map instead, the same shape:

const usersById = new Map(users.map((u) => [u.id, u] as const))
// Map<number, User>
Enter fullscreen mode Exit fullscreen mode

Map is the one I reach for 90% of the time at work. Object property names are stringified, which silently breaks any code that relied on the key being a number. Map keeps the key's identity.

When To Reach For Effect, Lodash, or Ramda

The flat array methods cover the everyday cases. There are three places where pulling in a library still earns its keep, and the choice of library matters more than it used to.

Effect when you want effects in the type system. Effect's Array and Stream modules give you groupBy, partition, chunksOf, dedupeWith, and a hundred more, all with the runtime layered in. If your service already uses Effect for error channels and dependency injection, the Array module fits the rest of the codebase. Skip Effect if it isn't already a dependency; the standard methods are enough.

Lodash for the edges: _.zipObject, _.invertBy, _.intersectionBy, _.differenceBy, deep merges with custom resolvers. The standard library has caught up to groupBy, chunk, uniq, and friends.

Ramda when the team writes data-last, point-free, curried-everywhere code. The argument order is the inverse of lodash (data last instead of data first), and the API leans into composition. Plain method chains? Ramda is friction.

The 2026 default for a new project: standard library first, write a utils.ts for the three-liners, reach for Effect if the project already uses it, and Lodash if you genuinely need one of its edges.

The Iterator Helpers (ES2025)

One more piece worth knowing about. ECMAScript 2025 finalised the iterator helpers proposal. Iterator.prototype now has map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every, and find (the same shape as Array, but lazy).

const firstTenActiveEmails = users
  .values()                                  // returns an Iterator
  .filter((u) => u.active)
  .map((u) => u.email)
  .take(10)
  .toArray()
Enter fullscreen mode Exit fullscreen mode

The lazy part is what makes them new. Array methods materialise a new array on every step. The iterator pipeline pulls one item end-to-end before pulling the next. For ten thousand users where you want the first ten active ones, the iterator chain stops at the first ten matches; the array chain processes all ten thousand twice (once for filter, once for map) and then slices. Most queries do not need the laziness. The ones that do (generators, paged APIs, file streams) are now first-class without a library.

Iterator helpers are in V8, JavaScriptCore, SpiderMonkey, and Node.js 22+. See V8's iterator-helpers post for the implementation status across runtimes.

The Mental Shift

The reason this whole post can fit on one screen of mapping is that PHP and JavaScript made opposite bets at the array layer.

PHP's array_* functions take the callback in the wrong position. Argument order is inconsistent even within the standard library: array_map($fn, $arr) versus array_filter($arr, $fn). The functions can't chain because they're functions, not methods. And the language refuses to distinguish lists from keyed maps, so array_filter quietly preserves keys (sometimes you wanted that, sometimes you didn't). Collection was the patch. Wrapping the array in an object gave you a chainable surface, consistent argument order, and the list/keyed-map distinction the language refused to make.

JavaScript's Array.prototype was, by accident, on the right side of all three problems. Methods are called on the array, so the chain is the natural shape. There's only one position for the callback, so argument order can't drift. Lists are arrays and keyed records are plain objects, so the two never collide.

The shift you are making, then, is not from PHP's array functions to JS's. It is from Collection's wrapper-and-unwrap dance to the methods being already there: on the array, on the object, in the iterator. The thing you used to spell collect($users)->where(...)->groupBy(...)->map(...)->values()->all() is users.filter(...), Object.groupBy(...), .map(...), and you skip ->values()->all() entirely because there is nothing to unwrap.

Stop wrapping. Stop reaching for the wrapper class out of muscle memory. The methods are flat on the array. The few that aren't are flat on Object, on Map, on Set, or on the iterator. You will write less code, not more.

Forward Motion

The next thing to translate after Collection is Eloquent. The closest TypeScript analog is Drizzle, and the mapping is messier than this one because the relational-modeling assumptions diverge: Eloquent leans on dynamic relationships and lazy loading; Drizzle leans on explicit joins and statically-typed query builders. That migration is a separate post, but if you are picking the next hill to climb after the array-methods one, that is the hill.

In the meantime, try a real query. Take the last Collection-heavy method in your Laravel codebase, port it to TypeScript using only the standard library plus the helpers above, and see whether you reach for lodash once. The answer will tell you whether your team needs it as a dependency at all.


If this reframing landed, PHP to TypeScript is the book it came from. The collections-to-arrays chapter walks the full mapping including the edge cases this post skipped: keyBy, flatMap, zip, takeUntil, and the iterator-helper queries that consume generators. There are also chapters on Eloquent to Drizzle, sync to async, and the parts of generics PHP did not prepare you for.

It is one of five books in The TypeScript Library:

  1. TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling.
  2. The TypeScript Type System — deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed→unions, coroutines→async/await.
  4. PHP to TypeScript — bridge for PHP 8+ developers. Sync→async paradigm, generics, discriminated unions.
  5. TypeScript in Production — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. Books 3 and 4 substitute for them if you speak JVM or PHP. Book 5 is for anyone shipping TS at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)