Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
In JavaScript, map() transforms every item in an array and returns a new array of the same length, filter() returns a new array with only the items that pass a test, and reduce() combines all the items into a single value, like a total or an object. None of them change the original array. Below I explain each one with practical examples, show how to chain them, and cover the mistakes that cost me the most debugging time.
For a small project that uses filter() on real data, see my offline habit tracker with localStorage. Every example was run with Node.js 20 (and Node 22 for Object.groupBy), and the comments at the end of each block are the real output. The methods work the same way in every modern browser.
Most of the examples use this small list of study tasks, saved as data.mjs, because real arrays are usually arrays of objects:
export const tasks = [
{ title: "Physics notes", subject: "Physics", minutes: 45, done: true },
{ title: "Math practice", subject: "Math", minutes: 60, done: false },
{ title: "Chemistry lab report", subject: "Chemistry", minutes: 90, done: false },
{ title: "Math revision", subject: "Math", minutes: 30, done: true },
{ title: "Physics problems", subject: "Physics", minutes: 50, done: false },
];
map() calls your function once for each item and builds a new array from whatever you return. Use it when you want "the same list, but each item changed".
import { tasks } from "./data.mjs";
const prices = [100, 250, 80];
const doubled = prices.map((price) => price * 2);
console.log(doubled);
const titles = tasks.map((task) => task.title);
console.log(titles);
// map also passes the index as the second argument
const numbered = titles.slice(0, 2).map((title, i) => `${i + 1}. ${title}`);
console.log(numbered);
console.log(prices); // the original array is unchanged
// Output:
// [ 200, 500, 160 ]
// [
// 'Physics notes',
// 'Math practice',
// 'Chemistry lab report',
// 'Math revision',
// 'Physics problems'
// ]
// [ '1. Physics notes', '2. Math practice' ]
// [ 100, 250, 80 ]
The callback gets the item first and the index second, which is handy for numbering. The input and output always have the same length. If you need fewer items, that's a job for filter().
filter() calls your function for each item and keeps the item if the function returns something truthy. Use it for "only the items where...".
import { tasks } from "./data.mjs";
const todo = tasks.filter((task) => !task.done);
console.log(todo.map((t) => t.title));
const longMath = tasks.filter((t) => t.subject === "Math" && t.minutes >= 45);
console.log(longMath.length, "long math task(s)");
const nothing = tasks.filter((t) => t.minutes > 500);
console.log(nothing); // no matches gives an empty array, not undefined
// Output:
// [ 'Math practice', 'Chemistry lab report', 'Physics problems' ]
// 1 long math task(s)
// []
When nothing matches, you get an empty array, not undefined or null. That means you can safely call .length or .map() on the result without checking first.
reduce() is the one that confused me the longest. It walks through the array carrying an accumulator, the running result, and your function returns the new accumulator each time. The second argument to reduce() is the starting value.
import { tasks } from "./data.mjs";
const numbers = [5, 10, 15];
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum);
// Total minutes across all tasks
const totalMinutes = tasks.reduce((total, task) => total + task.minutes, 0);
console.log(`${totalMinutes} minutes`);
// Count tasks per subject: the accumulator is an object
const perSubject = tasks.reduce((counts, task) => {
counts[task.subject] = (counts[task.subject] ?? 0) + 1;
return counts;
}, {});
console.log(perSubject);
// Output:
// 30
// 275 minutes
// { Physics: 2, Math: 2, Chemistry: 1 }
What helped me was logging each step:
[5, 10, 15].reduce((total, n) => {
console.log(`total=${total}, n=${n} -> ${total + n}`);
return total + n;
}, 0);
// Output:
// total=0, n=5 -> 5
// total=5, n=10 -> 15
// total=15, n=15 -> 30
The accumulator doesn't have to be a number. In the perSubject example it's an object that collects counts. The ?? 0 handles the first time a subject appears, when its count doesn't exist yet. Always return the accumulator, otherwise the next step gets undefined.
Grouping items by a key used to be the classic reduce() exercise. Modern JavaScript has a built-in for it:
import { tasks } from "./data.mjs";
// Object.groupBy: Node 21+, and all current major browsers
const bySubject = Object.groupBy(tasks, (task) => task.subject);
console.log(Object.keys(bySubject));
console.log(bySubject.Math.map((t) => t.title));
// Output:
// [ 'Physics', 'Math', 'Chemistry' ]
// [ 'Math practice', 'Math revision' ]
Object.groupBy is available in Node.js 21 and later and in current versions of the major browsers. It isn't in Node 20, which I confirmed by running it there, so if your code has to run on older environments, the reduce() version above still works everywhere.
Because map() and filter() return arrays, you can chain them. Each step reads like a sentence:
import { tasks } from "./data.mjs";
// Hours of study left on unfinished tasks
const hoursLeft = tasks
.filter((task) => !task.done) // keep unfinished tasks
.map((task) => task.minutes) // turn each into its minutes
.reduce((total, minutes) => total + minutes, 0) / 60; // add them up
console.log(`${hoursLeft.toFixed(2)} hours left`);
// Titles of unfinished tasks, longest first, without changing the original
const plan = tasks
.filter((t) => !t.done)
.toSorted((a, b) => b.minutes - a.minutes)
.map((t) => `${t.title} (${t.minutes} min)`);
console.log(plan);
// Output:
// 3.33 hours left
// [
// 'Chemistry lab report (90 min)',
// 'Math practice (60 min)',
// 'Physics problems (50 min)'
// ]
I put each method on its own line so it's easy to comment out a step while debugging. The second example uses toSorted(), which returns a sorted copy. The older sort() sorts the array in place, which can quietly change data other code depends on.
This style of small, pure functions, where the same data in always gives the same result out, is how I wrote the planner in my study app. Each task gets a score and the plan is built from the sorted list. I wrote about it in building an offline study planner with no backend. Pure functions like these are also very easy to test.
When I'm not sure, I ask what I want to get back:
map().filter().reduce().Real tasks usually need more than one. Here's a small report from a list of exam results that uses all three, each for its own job:
const results = [
{ name: "Rafi", mark: 72 },
{ name: "Nusrat", mark: 88 },
{ name: "Tanvir", mark: 35 },
{ name: "Mim", mark: 64 },
];
const passed = results.filter((r) => r.mark >= 40);
const average = passed.reduce((sum, r) => sum + r.mark, 0) / passed.length;
const names = passed.map((r) => r.name).join(", ");
console.log(`${passed.length} passed: ${names}`);
console.log(`Average of passing marks: ${average.toFixed(1)}`);
// Output:
// 3 passed: Rafi, Nusrat, Mim
// Average of passing marks: 74.7
You could do all of this inside one big reduce(), but splitting it up keeps each line simple. I'd rather loop over four items three times than read one clever line that nobody understands a week later. Notice that the average divides by passed.length. If nobody passed, that would be zero and the result would be NaN, so real code should check for an empty list first.
Sometimes filter() is more than you need:
import { tasks } from "./data.mjs";
console.log(tasks.find((t) => t.subject === "Chemistry").title); // first match
console.log(tasks.some((t) => t.minutes > 60)); // at least one?
console.log(tasks.every((t) => t.minutes >= 30)); // all of them?
console.log(tasks.findIndex((t) => !t.done)); // index of first match
// Output:
// Chemistry lab report
// true
// true
// 1
find() returns the first matching item itself (or undefined), not an array.some() and every() return true or false and stop as soon as they know the answer.findIndex() returns the position, or -1 if nothing matches.If you write filter(...)[0] or filter(...).length > 0, there's a clearer method for it.
const nums = [1, 2, 3];
const broken = nums.map((n) => { n * 2 }); // braces need a return
const fixed = nums.map((n) => { return n * 2; });
const short = nums.map((n) => n * 2); // or drop the braces
console.log(broken, fixed, short);
// Returning an object without braces needs parentheses
const objs = nums.map((n) => ({ value: n }));
console.log(objs);
// Output:
// [ undefined, undefined, undefined ] [ 2, 4, 6 ] [ 2, 4, 6 ]
// [ { value: 1 }, { value: 2 }, { value: 3 } ]
With curly braces, the arrow function has a body and needs an explicit return. Without braces, the expression is returned automatically. To return an object literal without braces, wrap it in parentheses, or JavaScript reads the braces as a function body.
const inputs = ["10", "10", "10"];
console.log(inputs.map(parseInt)); // index becomes the radix!
console.log(inputs.map((s) => parseInt(s, 10))); // pass only what you mean
console.log(inputs.map(Number));
// Output:
// [ 10, NaN, 2 ]
// [ 10, 10, 10 ]
// [ 10, 10, 10 ]
map() passes the item, the index and the array. parseInt takes a second argument, the radix, so it receives the index as the base. parseInt("10", 1) is NaN and parseInt("10", 2) is 2. Wrap it in an arrow function, or use Number.
const empty = [];
try {
empty.reduce((a, b) => a + b); // no initial value
} catch (error) {
console.log(error.constructor.name + ":", error.message);
}
console.log(empty.reduce((a, b) => a + b, 0)); // initial value makes it safe
// Output:
// TypeError: Reduce of empty array with no initial value
// 0
Without an initial value, reduce() uses the first item as the start, and with no first item it throws. I always pass an initial value. It also makes the type of the result obvious to anyone reading.
const users = [{ name: "Rafi", active: false }];
// Wrong: this changes the original objects
const activated = users.map((u) => { u.active = true; return u; });
console.log(users[0].active); // true - the original was modified
// Right: return a new object with spread
const users2 = [{ name: "Rafi", active: false }];
const activated2 = users2.map((u) => ({ ...u, active: true }));
console.log(users2[0].active, activated2[0].active);
// Output:
// true
// false true
map() creates a new array, but the objects inside are the same objects. Changing them changes the original data. Spread into a new object instead.
If you don't use the returned array, use forEach() or a for...of loop. A map() whose result is thrown away tells the reader the wrong thing. And don't pass an async callback to forEach(): it won't wait, as I show in my async/await tutorial.
map() returns a new array built from your return values. forEach() returns undefined and is only for side effects, like logging or updating the page.
No, they return new values. But if the items are objects and your callback modifies them, those objects are changed, because both arrays point to the same objects.
Each step is a separate loop, but for the arrays most apps deal with (hundreds or a few thousand items) the difference is too small to notice. Readability wins. If you ever process huge datasets, measure before optimising.
Not directly, but Object.entries(obj) turns an object into an array of [key, value] pairs you can map and filter, and Object.fromEntries() turns it back.
Use map() to change every item, filter() to keep some items and reduce() to boil them down to one value, and chain them to describe a data pipeline step by step. Always give reduce() a starting value, return from braced arrow functions, and avoid mutating objects inside callbacks. Python has the same idea built into the language as list comprehensions.
MDN's reference for Array.prototype.reduce() has more examples, and the pages for map and filter are linked from there.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.