Building a Type-Safe News Analysis API with Express

I still remember the first time I tried to migrate a legacy Express app to TypeScript. I thought it would be a weekend job. Just rename .js to .ts, fix a few squiggly lines, and bask in the glory of type safety, right?

Wrong.

Three days later, I was drowning in any types and fighting with the Request generic interface that seemed determined to make my life miserable. It felt like I was writing more definition files than actual code. But after building a few dozen APIs since then—including a recent market intelligence tool that ingests news streams—I’ve finally settled on a pattern that doesn’t make me want to throw my monitor out the window.

If you’re building a backend in 2026, you’re likely using TypeScript. And if you’re using Express, you need to stop fighting the framework and start making the compiler work for you. Here is how I actually build these things now, specifically focusing on an async API that handles external data.

The “Request” Problem

The biggest pain point in Express with TypeScript is typing the req and res objects. The default Express types are… loose. If you don’t lock them down, you end up guessing what’s in req.body.

TypeScript code on monitor - Learn how to document JavaScript/TypeScript code using JSDoc ...
TypeScript code on monitor – Learn how to document JavaScript/TypeScript code using JSDoc …

Here is the scenario: we are building an endpoint that takes a URL, scrapes the content (simulating a DOM operation), and sends it to an LLM for summarization. This is a classic async operation that can fail in twelve different ways.

First, let’s look at the setup. I don’t use the default Request type directly in my controllers anymore. It’s too verbose. I prefer using Zod to infer the types. It saves me from maintaining two separate sources of truth.

import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';

// 1. Define the schema for validation
const AnalyzeRequestSchema = z.object({
  body: z.object({
    url: z.string().url(),
    focusKeywords: z.array(z.string()).optional()
  })
});

// 2. Infer the TypeScript type from the schema
type AnalyzeRequest = z.infer<typeof AnalyzeRequestSchema>['body'];

// 3. Create a typed request interface
interface TypedRequest<T> extends Request {
  body: T;
}

This tiny bit of boilerplate prevents so many headaches. Now, when I access req.body.url, TypeScript knows it’s a string. If I try to access req.body.title, it yells at me. Simple.

Async Handlers and the DOM

Node.js is great at async I/O, but mixing it with heavy processing can get messy. For our market tool, we need to fetch HTML and parse it. Since we aren’t in a browser, we don’t have a native DOM. I use cheerio for this—it’s fast, synchronous, and uses jQuery-like syntax.

Here is the actual controller logic. Notice how I’m handling the async nature of the external API calls.

import axios from 'axios';
import * as cheerio from 'cheerio';

export const analyzeNewsController = async (
  req: TypedRequest<AnalyzeRequest>, 
  res: Response, 
  next: NextFunction
) => {
  try {
    const { url, focusKeywords } = req.body;

    // Step 1: Fetch the raw HTML
    // I always set a timeout because news sites can hang indefinitely
    const { data: html } = await axios.get(url, { timeout: 5000 });

    // Step 2: Load into a virtual DOM
    const $ = cheerio.load(html);

    // Step 3: DOM Manipulation / Extraction
    // We remove scripts and styles to clean up the text for the AI
    $('script').remove();
    $('style').remove();
    $('nav').remove();
    $('footer').remove();

    // Extract the main headline and article body
    const title = $('h1').first().text().trim();
    const content = $('article, main, .content').text().replace(/\s+/g, ' ').trim().slice(0, 2000);

    if (!content) {
      throw new Error('Could not extract meaningful content from this URL');
    }

    // Step 4: Simulate AI Processing (e.g., OpenAI call)
    const analysis = await simulateAIAnalysis(content, focusKeywords);

    return res.json({
      success: true,
      data: {
        title,
        source: url,
        marketSentiment: analysis.sentiment,
        summary: analysis.summary
      }
    });

  } catch (error) {
    // Pass errors to Express's global error handler
    next(error);
  }
};

// Mocking the AI function for this example
async function simulateAIAnalysis(text: string, keywords?: string[]) {
  // Simulating network delay
  await new Promise(resolve => setTimeout(resolve, 800)); 
  
  return {
    sentiment: text.includes("crash") ? "bearish" : "bullish",
    summary: Analyzed ${text.length} chars. Focused on: ${keywords?.join(', ') || 'General Market'}
  };
}

I see a lot of tutorials skip the error handling in async functions. Don’t do that. If you forget the try/catch block (or don’t use a wrapper library), a rejected promise will leave your request hanging until it times out. It’s a terrible user experience.

TypeScript code on monitor - Reduce Development Cost, Accelerate Code Delivery with TypeScript
TypeScript code on monitor – Reduce Development Cost, Accelerate Code Delivery with TypeScript

Structuring the Routes

You can’t just slap that controller into an index.ts file and call it a day. Well, you can, but you’ll hate yourself in three months when you need to add five more endpoints. I separate my routes, controllers, and validation logic.

Here is how I wire it up in the router. I use a middleware to validate the Zod schema before the controller even runs. This ensures my controller logic never deals with invalid data.

import { Router } from 'express';
import { analyzeNewsController } from './controllers/newsController';
import { validate } from './middleware/validation';
import { AnalyzeRequestSchema } from './schemas/newsSchemas';

const router = Router();

// POST /api/v1/market/analyze
router.post(
  '/market/analyze',
  validate(AnalyzeRequestSchema), // Fail fast if data is bad
  analyzeNewsController
);

export default router;

Why This Matters

TypeScript code on monitor - How to Make a VS Code Extension Using TypeScript: A Step-by-Step ...
TypeScript code on monitor – How to Make a VS Code Extension Using TypeScript: A Step-by-Step …

I was debugging a junior dev’s code last week. They were building a similar dashboard but had skipped the strict typing. They were passing req.body directly to their database query builder. The issue? The frontend was sending price as a string “100”, but the database expected a number.

Because they used any, TypeScript stayed silent. The app crashed in production.

If they had used the setup above, Zod would have caught that string at the gate and returned a 400 Bad Request before the database connection was even opened. That is the difference between “it works on my machine” and “it works in production.”

Building market intelligence tools or news aggregators involves dealing with messy, unpredictable data from the outside world. The DOM structure of news sites changes, APIs rate-limit you, and data formats drift. Your internal code shouldn’t be another source of chaos. Lock down your types, validate your inputs, and handle your async errors. Your future self will thank you.

Mateo Rojas

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *