The Express API: Where the Conversion Logic Actually Lives

Romaji to Kana Converter: Angular & Express

Chapter 5 · The Express API: Where the Conversion Logic Actually Lives

Three earlier chapters all pointed at this exact moment. Chapter 1 decided, up front, that this variant commits to a real Express-hosted conversion engine rather than treating client-vs-server as an open question. Chapter 2 built that engine as a plain, framework-agnostic module and said outright that "Chapter 5 moves the canonical copy into Express." Chapter 4 built ConvertService.convertText() around a shape designed specifically so this move would cost nothing. This chapter cashes all three of those promises in for real: the engine moves to Express, a genuine /api/convert route replaces the ping placeholder, and exactly one method in the Angular app changes.

Porting conversion-engine.ts Into a Plain JavaScript Express Module

The Angular project is TypeScript by default. The Express backend, scaffolded back in Chapter 1 with a plain npm init -y and require()-based server.js, has no TypeScript toolchain at all — no tsconfig.json, no compile step, nothing beyond node server.js. Adding one now, purely to preserve a handful of type annotations on one already-finished, already-tested module, would be real, ongoing cost (a build step, a compiled-output folder, one more thing that can drift out of sync) for a project this small.

TypeScript's own type annotations — Record<string, string>, the : string parameter type, the : { hiragana: string; katakana: string } return type — are compile-time-only. They're erased before the code ever runs; a TypeScript file and its own compiled JavaScript output execute identically. Porting conversion-engine.ts into a plain .js file is a mechanical strip-the-annotations exercise, not a rewrite:

// Before — src/app/conversion-engine.ts (Chapters 2-4, Angular project) export const KANA_MAP: Record<string, { hiragana: string; katakana: string }> = { a: { hiragana: 'あ', katakana: 'ア' }, // ...every other cell, unchanged... }; export function convert(romaji: string): { hiragana: string; katakana: string } { // ...unchanged body... } // After — romaji-converter-api/conversion-engine.js (this chapter, Express project) const KANA_MAP = { a: { hiragana: 'あ', katakana: 'ア' }, // ...every other cell, unchanged — same keys, same kana, no type annotation... }; function convert(romaji) { // ...unchanged body... } module.exports = { convert };

Every other declaration in the file — the rest of KANA_MAP, both macron tables from Chapter 3, sokuonLength, tokenizeScript, PARTICLE_OVERRIDES, and LEXICALIZED_EXCEPTIONS — gets the identical mechanical treatment: every type annotation deleted, every actual value left exactly as it was. Nothing worth re-pasting in full here, since none of it changed in substance.

Verifying the Port Didn't Change Anything

A claim like "erasing types doesn't change behavior" is exactly the kind of thing this course has learned not to take on faith — Chapter 3 found two real bugs by actually running code instead of assuming it was correct. The ported module gets the same treatment, run directly against Chapter 3's own two verification sentences:

$ node -e " const { convert } = require('./conversion-engine.js'); console.log(JSON.stringify(convert('gakkou e ikimasu'))); console.log(JSON.stringify(convert('konnichiwa'))); " {"hiragana":"がっこう へ いきます","katakana":"ガッコウ ヘ イキマス"} {"hiragana":"こんにちは","katakana":"コンニチハ"}
Byte-Identical to the Original TypeScript Output
Both results match Chapter 3's own published output for these exact two sentences, character for character. That's the real, run confirmation that a type-erasure port genuinely is behavior-preserving here — not an assumption borrowed from "that's how TypeScript is supposed to work," but the same run-it-and-check discipline this course has used since Chapter 2's own tokenizer bugs.
Aspectconversion-engine.ts (Chapters 2-4)conversion-engine.js (this chapter)
Locationsrc/app/conversion-engine.ts — deleted below, once nothing imports itromaji-converter-api/conversion-engine.js
Export styleexport function convert(...)module.exports = { convert }
Type annotationsPresent — compile-time only, erased before runningAbsent — never had any effect on what actually runs
Runtime behaviorVerified against every example in Chapters 2-3Verified above — byte-identical to the TypeScript version

Building the Real /api/convert Route

Chapter 1's server.js already has express.json() wired up, so a POST body is already parsed into req.body with no extra setup. A first, deliberately unguarded version — require the ported module, read romaji straight off the request, convert it, respond:

// server.js — a first, unguarded attempt const { convert } = require('./conversion-engine'); app.post('/api/convert', (req, res) => { const result = convert(req.body.romaji); res.json(result); });

Sending a request with no romaji field at all — the honest, realistic case of a malformed or incomplete client request, not a contrived one:

$ curl -i -X POST http://localhost:4000/api/convert \ -H "Content-Type: application/json" -d '{}' HTTP/1.1 500 Internal Server Error Content-Type: text/html; charset=utf-8 <!DOCTYPE html> <html>... <pre>TypeError: Cannot read properties of undefined (reading 'split') at convert (/romaji-converter-api/conversion-engine.js:91:24) at /romaji-converter-api/server.js:14:18 at Layer.handleRequest (/romaji-converter-api/node_modules/router/lib/layer.js:152:17) ...</pre> </html>
Real, Run Output — Against the Actual Currently-Installed Express Version
That response is genuine, run output, not a hypothetical — convert(undefined) throws the moment it tries undefined.split(' '). Verified directly against the real package that Chapter 1's own npm install express command actually installs, with no version pinned: Express 5.2.1, as of this writing — not the older Express 4 that plenty of still-circulating tutorials assume. Express's own router wraps a synchronous route handler in an internal try/catch, so this thrown error doesn't crash the whole server process — a second request right after this one still succeeds fine — but it also doesn't stop a raw, unhelpful stack trace, real file paths included, from being sent straight to whatever called the API.

A real check, and a clean, deliberate 400 response, close the gap entirely:

// server.js — the real, final route const { convert } = require('./conversion-engine'); app.post('/api/convert', (req, res) => { const { romaji } = req.body; if (typeof romaji !== 'string' || romaji.trim() === '') { return res.status(400).json({ error: 'romaji field is required and must be a non-empty string', }); } const result = convert(romaji); res.json(result); });

Testing It Directly, Before Angular Ever Touches It

The same two requests, against the validated route — a real, run comparison, not just an assertion that the fix works:

$ curl -i -X POST http://localhost:4000/api/convert \ -H "Content-Type: application/json" -d '{}' HTTP/1.1 400 Bad Request Content-Type: application/json; charset=utf-8 {"error":"romaji field is required and must be a non-empty string"} $ curl -X POST http://localhost:4000/api/convert \ -H "Content-Type: application/json" -d '{"romaji":"gakkou e ikimasu"}' {"hiragana":"がっこう へ いきます","katakana":"ガッコウ ヘ イキマス"}

A clean, structured error for the bad request; the exact same byte-identical result from the earlier verification script for the good one — confirmed working correctly over a real HTTP round trip, before a single line of Angular code has been touched.

Retiring the Ping Placeholder

/api/ping already did its one job — proving Angular and Express could talk to each other, back in Chapter 1 — and Chapter 4 already removed the only code that ever called ConvertService.ping(). The method itself, though, was left sitting in convert.service.ts unused. Now that a real route exists, both halves of the placeholder come out together:

// server.js — the ping route is removed entirely, /api/convert is the only route now // (app.get('/api/ping', ...) deleted)

Rewiring ConvertService: From a Function Call to a Real HTTP Request

Exactly the change Chapter 4 named in advance — the import of convert is gone, and convertText()'s two-line in-process body is replaced with a real HttpClient call, still updating the same two signals, still returning void:

// src/app/convert.service.ts — the real Chapter 5 version import { Injectable, inject, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; // The one thing this service now needs to know about the shape of a // successful response — nothing else about the request/response cycle. interface ConvertResponse { hiragana: string; katakana: string; } @Injectable({ providedIn: 'root' }) export class ConvertService { private http = inject(HttpClient); private readonly hiraganaResult = signal(''); private readonly katakanaResult = signal(''); readonly hiragana = this.hiraganaResult.asReadonly(); readonly katakana = this.katakanaResult.asReadonly(); readonly hasResult = computed(() => this.hiraganaResult().length > 0); convertText(romaji: string): void { this.http .post<ConvertResponse>('http://localhost:4000/api/convert', { romaji }) .subscribe({ next: (result) => { this.hiraganaResult.set(result.hiragana); this.katakanaResult.set(result.katakana); }, error: (err) => { // A visible, user-facing error state is Chapter 6's own job — for now, // this at least stops a failed request from failing silently. console.error('Conversion request failed:', err); }, }); } }

src/app/conversion-engine.ts is deleted from the Angular project outright — Chapter 2 called it correctly back when the file was first written: this was always going to be a temporary home for it, not its canonical one, and nothing in the frontend imports it anymore.

Confirming Chapter 4's Own Promise, Kept
Compare this method against Chapter 4's version: same name, same parameter, same void return type, same two signals updated. Neither RomajiInputComponent nor KanaOutputComponent needed a single line changed — exactly what Chapter 4's own finding-box promised, now genuinely delivered rather than just planned.

A Real Angular-Specific Gotcha: Cold Observables

Every backend-calling example on this site outside this course uses fetch(), whose returned Promise starts executing the instant it's called — attaching a .then() later is optional, not a prerequisite for the request actually firing. HttpClient genuinely doesn't work that way, and forgetting the difference produces a real, silent failure with no error message at all:

// BROKEN — compiles cleanly, runs with no error, does nothing convertText(romaji: string): void { this.http.post<ConvertResponse>('http://localhost:4000/api/convert', { romaji }); // No .subscribe() -- no request is ever actually sent. }
Why Nothing Happens — Checked in the DevTools Network Tab
Calling that broken version produces zero entries in the browser's own Network tab — not a failed request, not a pending one, nothing at all. HttpClient methods return a genuinely cold Observable: the real HTTP call is the Observable's own producer function, and RxJS never runs a producer function until something subscribes to it. .post(...) alone only builds a description of a future request; .subscribe() is what actually triggers it. This is a real, well-documented structural difference from fetch()'s own eager Promise, and exactly the kind of thing someone used to this site's own React/Next.js-based courses would genuinely trip over here.

Confirming the Full Loop, End to End

Running npx nodemon server.js and ng serve in their own terminals and typing into the input box reproduces the exact same visible behavior as Chapter 4 — the output panel still updates on every keystroke. What's different underneath is real now: opening the browser's own DevTools Network tab while typing shows a genuine POST request to /api/convert firing on every single keystroke, each one carrying a real response this service reads straight off.

A Real, Deliberate Cost of Chapter 1's Own Decision
Committing to server-side conversion from the outset means every keystroke now costs a genuine network round trip, not a free in-process function call. That's not a bug introduced by this chapter — it's the honest consequence of Chapter 1's own architectural choice, made explicit rather than papered over. Softening it with a debounce is deliberately left for Chapter 6, since it's a UI/UX concern about how often onInput() itself fires, not something this chapter's own API work needs to solve.

Hands-On Exercises

Exercise 1

Port conversion-engine.ts into a plain conversion-engine.js exactly as this chapter describes (every type annotation removed, export swapped for module.exports, no other changes). Run it directly with node against both of Chapter 3's own verification sentences ("gakkou e ikimasu" and "konnichiwa") and confirm the output is byte-identical to what Chapter 3 originally published.

📄 View solution
Exercise 2

Build the real /api/convert route with the validation this chapter describes. Send it a request with no romaji field and confirm you get a clean 400 JSON error rather than a 500 stack trace, then send it a valid request and confirm the real, correct kana comes back exactly as verified above.

📄 View solution
Exercise 3

Rewire ConvertService.convertText() to call the real /api/convert endpoint exactly as this chapter describes, with no changes to RomajiInputComponent or KanaOutputComponent from Chapter 4. Confirm typing "sushi" still correctly shows すし and スシ in the output panel, and confirm in the DevTools Network tab that a real POST request now fires for each keystroke.

📄 View solution

Chapter 5 Quick Reference

  • The port — conversion-engine.ts moves to Express as plain conversion-engine.js; type annotations are compile-time-only, so stripping them is mechanical, and re-running Chapter 3's own test sentences confirms byte-identical output
  • The real route — POST /api/convert, validated with a typeof/empty-string check before ever calling convert()
  • Unguarded vs. validated, verified live — a missing romaji field crashes an unguarded handler into a real 500 HTML stack-trace leak (confirmed against the actual installed Express 5.2.1); the validated version returns a clean 400 JSON error instead
  • Dead code removed — /api/ping and ConvertService.ping() both come out, now that a real route exists
  • convertText() rewired — same name, same parameter, same void return type; only its internals swap a direct function call for an HttpClient POST + .subscribe(), exactly as Chapter 4 promised — zero changes to either component
  • Cold Observables — forgetting .subscribe() sends no request at all, with no error either; unlike fetch()'s own eager Promise, HttpClient does nothing until something subscribes
  • The real cost, named honestly — every keystroke now fires a genuine network request; softening that with a debounce is deliberately left for Chapter 6
  • Next chapter: Building the Input/Output UI — the actual interface this tool has been missing since Chapter 4