JSON to Bot Message Transformation
Welcome back! In our last lesson, we successfully integrated an external API call into a grammY command handler, enabling your bot to fetch live data from the web. You learned the fundamental pattern of using fetch within an async handler, checking the response, and parsing the JSON.
However, fetching data is only half the story. Raw JSON from an API is rarely suitable for direct presentation to a user. This lesson focuses on the crucial next step: parsing and transforming that JSON into well-structured, clearly formatted bot messages. Drawing on your extensive front-end development experience, you can think of this as taking a raw API response and rendering it into a user-friendly component—only instead of the DOM, our target is a Telegram chat window. We'll move from simple string concatenation to creating clean, formatted lists and even tables.
From Raw JSON to Structured Messages
The /joke command we built previously handled a very simple JSON object. Real-world APIs often return more complex structures, such as arrays of objects.

Let's create a new command, /posts, that fetches a list of blog posts from the JSONPlaceholder API. This will be our testbed for data transformation.
First, add the new command handler to your src/bot.ts file. For now, we'll just fetch the data and log it to see its structure.
// In src/bot.ts, before bot.start()
bot.command("posts", async (ctx) => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!response.ok) {
await ctx.reply("Sorry, I couldn't fetch the posts right now.");
console.error("API call failed:", response.status, response.statusText);
return;
}
const posts = await response.json(); // This will be an array of post objects
// For now, let's just see the raw data.
// This is ugly and not user-friendly!
await ctx.reply(JSON.stringify(posts, null, 2));
} catch (error) {
console.error("Error fetching posts:", error);
await ctx.reply("An unexpected error occurred.");
}
});
If you run your bot and send the /posts command, you'll get a blob of unformatted JSON. Our goal is to turn that into a clean list.
Formatting with HTML and Markdown
The simplest way to format messages is by using Telegram's built-in parse_mode. The ctx.reply method accepts an options object where you can set parse_mode to either 'HTML' or 'MarkdownV2'. Given your background, HTML mode will likely feel very natural.
Let's transform our array of post objects into a formatted list. This process is identical to how you might map over an array to render a list of components in a web application.
// inside the /posts command handler, replace the JSON.stringify line
const posts: { id: number; title: string; body: string }[] = await response.json();
const message = posts
.map(post => `<b>${post.id}. ${post.title}</b>\n<i>${post.body.substring(0, 100)}...</i>`)
.join('\n\n');
await ctx.reply(message, { parse_mode: 'HTML' });
In this code:
- We define a type for the post objects for better TypeScript support.
- We use
Array.prototype.map()to iterate over thepostsarray. - For each post, we create a string using HTML tags for bold (
<b>) and italics (<i>). join('\n\n')combines the formatted strings for each post, separated by a double newline.- Finally, we call
ctx.replywith our generated message and specify{ parse_mode: 'HTML' }.
This produces a much more readable and professional-looking result than the raw JSON dump.
Type-Safe Formatting with the parse-mode Plugin
While building HTML strings manually works, it can become cumbersome and error-prone, especially with complex formatting or when dealing with user input that might need escaping. The official grammY parse-mode plugin offers a more robust, declarative, and type-safe solution.
First, install the plugin:
bun add @grammyjs/parse-mode
This plugin provides utilities that abstract away the low-level details of Telegram's message entities. To understand its capabilities, please review its official documentation.
Parse Mode Plugin (parse-mode) | grammY
This is the official documentation for the parse-mode plugin. It provides a comprehensive overview of the two main formatting approaches it offers.
Please read the following sections to get a solid grasp of how the plugin works: Start with the Introduction to understand the problem the plugin solves (managing message entities). Next, review the "Two Approaches" section. It introduces the fmt tagged template and the FormattedString class, which are the core tools you'll use. Finally, look over the code in the fmt usage example and the FormattedString usage example. These demonstrate the practical application of both methods.
As you saw in the documentation, the FormattedString class is perfect for programmatically building a message from an array of data. Let's refactor our /posts command to use it.
// Add this import at the top of src/bot.ts
import { FormattedString } from "@grammyjs/parse-mode";
// ... inside the /posts command handler
const posts: { id: number; title: string; body: string }[] = await response.json();
// Initialize an empty FormattedString
const message = new FormattedString("");
// Loop through the data and append formatted parts
posts.forEach(post => {
message.bold(`${post.id}. ${post.title}`).plain("\n");
message.italic(`${post.body.substring(0, 100)}...`).plain("\n\n");
});
// The plugin generates the text and entities for you.
// ctx.reply can accept a FormattedString object directly.
if (message.text) {
await ctx.reply(message);
} else {
await ctx.reply("No posts found.");
}
This approach is cleaner and safer. You are no longer manually handling HTML tags as strings; instead, you are calling methods like .bold() and .italic(), and the library correctly constructs the underlying text and entities array for the Telegram API.
Case Study: Rendering Tabular Data
Your goal includes building "tech UIs." A common requirement is displaying structured data in a table. While Telegram doesn't have a native table element, we can simulate one effectively using a monospace font, which is rendered using <code> and <pre> tags in HTML mode.
Let's aim to create a formatted table like the one below, which you might get from an API listing available chat rooms.

Imagine our API returns the following JSON for a /rooms command:
[
{"id": 1, "name": "South Carolina", "capacity": 9},
{"id": 2, "name": "Colorado", "capacity": 9},
{"id": 3, "name": "Maryland", "capacity": 10},
{"id": 8, "name": "Oregon", "capacity": 7},
{"id": 15, "name": "California", "capacity": 55}
]
To transform this into a table, we need to use string padding (padEnd) to ensure the columns align correctly.
// Example for a new /rooms command
bot.command("rooms", async (ctx) => {
// In a real bot, you'd fetch this data. Here we'll use a mock.
const rooms = [
{"id": 1, "name": "South Carolina", "capacity": 9},
{"id": 2, "name": "Colorado", "capacity": 9},
{"id": 3, "name": "Maryland", "capacity": 10},
{"id": 8, "name": "Oregon", "capacity": 7},
{"id": 15, "name": "California", "capacity": 55}
];
let table = "<pre><code>";
table += "ID | Room Name | Capacity\n";
table += "---+------------------+----------\n";
rooms.forEach(room => {
const id = room.id.toString().padEnd(2);
const name = room.name.padEnd(18);
const capacity = room.capacity.toString().padEnd(10);
table += `${id} | ${name} | ${capacity}\n`;
});
table += "</code></pre>";
await ctx.reply(table, { parse_mode: "HTML" });
});
This technique gives you precise control over the layout, allowing you to present complex, structured data in a highly readable format—a powerful tool for any utility bot.
The principle of parsing and transforming data is universal, even when the data source is more complex than a simple REST API. The following video shows a bot that processes raw event data from the Ethereum blockchain. While the domain is different, the pattern is the same.
Build a Telegram bot for on-chain events with Typescript
This video from Alchemy demonstrates a bot that listens for on-chain events. Pay close attention to the handler function where the raw, low-level data is processed into a human-readable notification.
Watch the segment from the handler logic. Notice how the TypeScript code performs the exact "parse and transform" pattern we've discussed: It drills down into a nested data structure to get the logs. It loops through the array of logs. It extracts specific fields from each log object. It transforms the raw data (e.g., converting hexadecimal to decimal, adjusting for decimal places). It constructs a final, readable string message to be sent to the user. This reinforces that no matter how complex the source data, the core task is always to extract, transform, and format.
Conclusion
In this lesson, you've learned to bridge the gap between raw API data and a polished user experience. By mastering data transformation and formatting, you can make your bot not just functional, but truly useful and easy to interact with.
Key Takeaways:
- API responses, especially those containing arrays of objects, must be transformed before being sent to the user.
- You can leverage standard JavaScript array methods like
.map()and.forEach()to process JSON data, a pattern you're already very familiar with. - Telegram supports message formatting via
parse_mode: 'HTML'or'MarkdownV2', with HTML often being more forgiving and familiar for web developers. - For robust, type-safe formatting, the
@grammyjs/parse-modeplugin and itsFormattedStringclass provide a declarative API to build complex messages. - You can create table-like layouts for structured data using
<pre><code>tags and string padding for column alignment.
Now that we can successfully fetch, parse, and display data, our next step is to make our integration more resilient. In the next lesson, we will focus on implementing robust error handling for API failures, ensuring your bot can respond gracefully when an external service is down or returns an unexpected error.
Can't find a good explanation? Sign up and we'll make it for you
Sign up