Exploring Data Transformations with TypeScript
While working on this very blog, I realized I wanted to group my articles by month. Simple enough, right? Well, turns out, it took me a bit of trial and error to get it working the way I wanted.
The goal was to transform a flat array of blog posts like this:
type TBlogPost = {
slug: string;
metadata: {
title: string;
publishedAt: string;
description: string;
};
content: string;
};
// Example input
const blogs: TBlogPost[] = [
{
slug: "sugar-high-themes",
metadata: { ... },
content: "...",
},
{
slug: "data-transformations-with-typescript",
metadata: { ... },
content: "...",
},
];
into an object grouped by year and month, like this:
const groupedArticles = {
"2025-1": [
{
slug: "sugar-high-themes",
metadata: { ... },
content: "...",
},
],
"2025-2": [
{
slug: "data-transformations-with-typescript",
metadata: { ... },
content: "...",
},
],
};
It sounded straightforward in my head, but i ran into a few bumps along the way. After some experimenting (and a lot of console.logs), this is the function I ended up with:
const groupByMonth = (posts: TBlogPost[]) => {
const grouped: Record<string, TBlogPost[]> = {};
posts.forEach((post) => {
const date = new Date(post.metadata.publishedAt);
const key = `${date.getFullYear()}-${date.getMonth()}`; // Year-Month format
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(post);
});
return grouped;
}
A few things are happening:
- I am initializing and empty
groupedobject. - I loop through the flat array of blogs and extract the date.
- For every iteration i check if an array with the extracted date as the key exists in the
groupedobject. If it does, I simply add the post to that array. If it does not, I create an empty array and assign the date as the key and then add the article to the newly created array.
And just like that, the articles were grouped by month, ready to be rendered like so:
<div>
{Object.entries(groupedArticles).map(([month, articles]) => (
<MonthGroup key={month} month={month} articles={articles} />
))}
</div>
Even though this seemed simple, it made me realize I am not that comfortable with transforming objects in TypeScript. Things like changing their structure or reshaping data to fit a specific format gave me a harder time than I expected.
So I decided to take a step back and practice more. I asked ChatGPT for some tricky data transformation challenges to work on, and they turned out to be really helpful. Below are a few of them, my solutions, and the things I learned.
Q1. Flatten Deeply Nested Object into Dot Notation Keys
For this challenge, the goal is to take a deeply nested object and flatten it into a single-level object where each key represents the full path using dot notation.
// Example Input
const nestedObj = {
user: {
name: "John",
address: {
city: "Nairobi",
zip: "00100",
},
occupation: {
role: "Junior dev",
company: "sort.ai",
},
},
};
// Expected Output
const flatObj = {
"user.name": "John",
"user.address.city": "Nairobi",
"user.address.zip": "00100",
"user.occupation.role": "Junior dev",
"user.occupation.company": "sort.ai",
};
At first, I thought Object.entries() and Object.fromEntries() would handle this perfectly. Turns out, I was just falling into the law of the hammer bias.
After hitting a wall, I scrapped that idea and started exploring Object.keys() together with reduce(), thanks to a helpful tip I found while researching. One thing I knew for sure was that recursion would be necessary since the object could be nested at any depth.
Here’s the final function I ended up with:
function flattenObject(obj: Record<string, any>, parentKey = "") {
return Object.keys(obj).reduce((acc, key) => {
const newKey = parentKey ? `${parentKey}.` + key : key;
if (
typeof obj[key] === "object" &&
obj[key] !== null &&
Object.keys(obj[key]).length > 0
) {
Object.assign(acc, flattenObject(obj[key], newKey));
} else {
acc[newKey] = obj[key];
}
return acc;
}, {});
}
How it works
The function takes two things , the object we want to flatten, and a parentKey that keeps track of the current path (like user or user.address).
It loops through all the keys using Object.keys(), and for each key, it checks if the value is another object.
- If it is, the function calls itself (that’s the recursion part) to flatten the nested object.
- If it’s not an object, that means we’ve hit a regular value, so we just add it to the final result using the full key path (like
user.address.city).
I used reduce() to collect everything into a single flat object, and Object.assign() to merge results from deeper levels back into the main object.
The key trick here is keeping track of the full path using parentKey, so everything ends up with the right dot notation.
This one took me a while to figure out, but it made me way more comfortable with recursion, reduce(), and working with nested objects in general. (🧢)
Q2. Group and Sort Orders by Month, then by Total Price (Descending)
We’ve got some orders, and we want to group them by month and sort them properly.
// example input
const orders = [
{ id: 3, total: 300, date: "2024-01-22" },
{ id: 1, total: 100, date: "2024-02-15" },
{ id: 2, total: 50, date: "2024-02-18" },
];
// example output
const groupedOrders = {
"2024-02": [
{ id: 1, total: 100, date: "2024-02-15" },
{ id: 2, total: 50, date: "2024-02-18" },
],
"2024-01": [
{ id: 3, total: 300, date: "2024-01-22" }
],
};
Implementation
type TOrder = {
id: number;
total: number;
date: string;
};
function groupAndSortOrders(orders: TOrder[]) {
const result: Record<string, TOrder[]> = {};
// 1. Group orders by YYYY-MM
for (const order of orders) {
const month = order.date.substring(0, 7);
(result[month] || []).push(order);
}
// 2. Sort each group by total price (descending)
for (const month in result) {
result[month].sort((a, b) => b.total - a.total);
}
// 3. Sort months in descending order
return Object.fromEntries(
Object.entries(result).sort(([a], [b]) => b.localeCompare(a))
);
}
We start by initializing an empty result object. Next, we loop through the data and since we want to group orders by month and year (YYYY-MM), we extract the first 7 characters from the date field. For each order, we check if a group (month-year) already exists in result.
- If it does, we add the order to that group.
- If it doesn’t, we create a new array for that month and add the order.
Here’s the section that handles the logic:
const month = order.date.substring(0, 7);
if (!result[month]) result[month] = [];
result[month].push(order);
Next we sort orders within each month. Once all orders are grouped by months, we sort each group based on total price in descending order. This ensures that within each month, the most expensive orders come first.
result[month].sort((a, b) => b.total - a.total);
Since total is already a number, sorting is straightforward.
The final step is sorting the months in descending order. To do this we:
- Convert the
resultobject into an array usingObject.entries(). - Sort the array so that the latest months appear first using
localeCompare(). - Convert it back to an object using
Object.fromEntries().
return Object.fromEntries(
Object.entries(result).sort(([a], [b]) => b.localeCompare(a))
);
Wrapping up
Working through these transformations was a solid reminder that even “simple” tasks can have a few gotchas. It also made me slightly more comfortable with recursion, sorting, and reshaping data in TypeScript. Definitely a good brain workout.
I originally planned to cover more in this post, but it was getting a bit too long. Instead of cramming everything in, I figured I’d break it up into smaller parts and cover the rest later.