Introduction
This is my seventh article as a Java engineer learning TypeScript from scratch.
In my previous article, I built a CLI that calls an external weather API (Open-Meteo) with fetch, and learned about async/await, response type definitions, and error handling with response.ok. But that was "read-only (GET)" against "someone else's API." This time I go a step further: I build the API server myself and then build an HTTP client that calls it.
- Backend: a REST API server built with FastAPI (Python)
- Client: a TypeScript CLI that calls that API
What I focused on this time:
- Implementing GET and POST with
fetch(previously GET only) - The
Content-Type: application/jsonheader on POST (a 422 error I fixed myself) - How where you place try-catch changes behavior (continue vs. exit)
- The difference between
returnandthrow error(close to Java'sthrows) - Narrowing the
unknowntype witherror instanceof Error - FastAPI basics (Spring-like decorators,
BaseModel,HTTPException)
Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.
📝 Scope of this article
This article centers on the TypeScript client. The Python (FastAPI) side is introduced compactly, as "the thing the client calls."
My Learning Style (AI Transparency)
💡 Learning companions
I use Claude Pro (design discussions and Q&A) and Cursor Pro (coding support) as learning companions.
My rules:
- I write all the code myself — I never ask AI to write code for me
- AI helps with hints, spec clarification, and bug spotting
- I make sure I understand why something works before moving on
In this article, I clearly separate "what I implemented myself" from "what I asked AI for."
What I Built
Pick a menu option in the CLI, and it gets a list of items, gets an item by ID, or adds an item — all against my own API server.
Custom API Call CLI tool
================================
1. Get all items
2. Get item by ID
3. Add an item
4. Exit
================================
Choose: 1
[
{ id: 1, name: 'apple', price: 100 },
{ id: 2, name: 'banana', price: 200 },
{ id: 3, name: 'cherry', price: 300 }
]
Data is held in memory on the server, so it resets when the server restarts (a deliberate simplification for local learning).
📦 Repositories
- Client (TypeScript): https://github.com/uya0526-design/simple_api_ts
- Server (Python / FastAPI): https://github.com/uya0526-design/simple_api_py
System Overview
This project is split across two repositories. The roles are simply "server and client."
[ TypeScript CLI ] --- HTTP (fetch) ---> [ FastAPI server ]
simple_api_ts simple_api_py
- GET /items - holds items in memory
- GET /items/{id} - accepts GET / POST
- POST /items - returns 404 via HTTPException
The endpoint spec:
| Method | Path | Description |
|---|---|---|
| GET | /items |
Returns all items |
| GET | /items/{id} |
Returns one item (404 if not found) |
| POST | /items |
Adds an item (id auto-assigned by the server) |
Backend: FastAPI Server (Python)
First I need something to call. This was my first time touching FastAPI, but to an ex-Java engineer's eyes it looked remarkably close to a Spring Boot controller.
Setup
python -m venv venv # project-local isolated environment
.\venv\Scripts\Activate.ps1 # activate (Windows PowerShell)
pip install fastapi uvicorn # install packages
pip freeze > requirements.txt # record dependencies
uvicorn main:app --reload # start the dev server
I understood venv as "per-project isolation," close to a Maven local repository or Node's node_modules, and uvicorn main:app --reload as the dev server equivalent of TypeScript's ts-node.
Implementing the endpoints
Routes are defined in main.py. The decorator feel is just like Spring.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
# internal / response use (has id)
class Item(BaseModel):
id: int
name: str
price: int
# request-receiving use (no id — auto-assigned by server)
class ItemCreate(BaseModel):
name: str
price: int
items = [
Item(id=1, name="apple", price=100),
Item(id=2, name="banana", price=200),
Item(id=3, name="cherry", price=300),
]
@app.get("/items")
def get_items():
return items
@app.get("/items/{item_id}")
def get_item(item_id: int):
for item in items:
if item.id == item_id:
return item
raise HTTPException(status_code=404, detail="Item not found")
@app.post("/items")
def create_item(item: ItemCreate):
new_id = len(items) + 1
new_item = Item(id=new_id, name=item.name, price=item.price)
items.append(new_item)
return new_item
Splitting RequestDTO and ResponseDTO
The main design decision was separating the receiving type (ItemCreate) from the internal/return type (Item).
-
ItemCreate: onlynameandprice. Noid(the client shouldn't send it) -
Item: includesid. The server assigns it
Avoiding "id can go in either one" and making each type's responsibility clear — I realized this is the same idea as separating RequestDTO and ResponseDTO in Java.
Java mapping (FastAPI to Spring)
| FastAPI / Python | Equivalent Java concept |
|---|---|
@app.get("/items") |
@GetMapping("/items") |
@app.post("/items") |
@PostMapping("/items") |
{item_id} path parameter |
@PathVariable |
BaseModel |
DTO class |
item: ItemCreate (typed argument) |
@RequestBody |
HTTPException |
ResponseStatusException |
raise |
throw |
len(items) |
.size() |
uvicorn main:app --reload |
ts-node |
/docs (auto Swagger UI) |
manual setup like SpringFox |
The biggest surprise was that just visiting /docs auto-generates a Swagger UI — coming from setting that up by hand, that felt like magic.
That's the server done. Now for the main event: the TypeScript client.
Client: TypeScript CLI
types.ts — Mirror the server's types
The client types mirror the server's DTOs. Dropping id from ItemCreate follows the same reasoning.
export interface Item {
id: number;
name: string;
price: number;
}
export interface ItemCreate {
// no id — the server auto-assigns it
name: string;
price: number;
}
📝 An interface closes with
}, not};At first I wrote the end of the
interfaceas};. The correct form is}(watch the difference from aclassor afunctionexpression). The trailing;on properties works with or without it, but by TypeScript convention it's common to include it.
api.ts — GET and POST with fetch
Previously I only did GET; this time I add POST too.
import { Item, ItemCreate } from "./types";
const API_ENDPOINT = "http://localhost:8000";
export async function getItems(): Promise<Item[]> {
const response = await fetch(`${API_ENDPOINT}/items`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
}
export async function getItemById(id: number): Promise<Item> {
const response = await fetch(`${API_ENDPOINT}/items/${id}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
}
export async function createItem(inputItem: ItemCreate): Promise<Item> {
const response = await fetch(`${API_ENDPOINT}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inputItem),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
}
Two things I improved from AI feedback.
1. Collect the base URL into a constant
At first I hardcoded "http://localhost:8000" in all three functions. I pulled it into a single const API_ENDPOINT to make changes easier.
2. The POST body is fine as JSON.stringify(inputItem)
At first I expanded the fields: JSON.stringify({ name: inputItem.name, price: inputItem.price }). But since inputItem is the ItemCreate type and is guaranteed to hold only name and price, JSON.stringify(inputItem) is enough. The type makes the code shorter — a nice example.
index.ts — Interactive menu and input validation
I wrap readline in a Promise and run a menu loop with while (true) + switch (an application of what I built up in previous projects).
const id = await askQuestion("Enter ID: ");
if (isNaN(parseInt(id))) {
console.log("Please enter a number");
continue;
}
The important part here is where to place try-catch (more below). In "get item by ID" (case "2"), the CLI was exiting entirely when the server returned 404, so I added try-catch inside the case to keep the loop going.
case "2": {
const id = await askQuestion("Enter ID: ");
if (isNaN(parseInt(id))) {
console.log("Please enter a number");
break;
}
try {
const item = await getItemById(parseInt(id));
console.log(item);
} catch (error) {
if (error instanceof Error && error.message.includes("404")) {
console.log("Item not found");
} else {
console.error(error);
throw error; // delegate unexpected errors to main().catch()
}
}
break;
}
api.test.ts — Mocking fetch (success and failure)
Seven tests total. I reuse jest.spyOn(global, "fetch") from last time. This time, for the failure case, I used mockRejectedValue to reproduce fetch itself failing.
test("throws when the API returns an error", async () => {
jest.spyOn(global, "fetch").mockRejectedValue(new Error("network error"));
await expect(getItems()).rejects.toThrow();
});
afterEach(() => {
jest.restoreAllMocks();
});
For the add (POST) test, I compared the count (length) before and after to confirm exactly one item was added.
PASS src/__tests__/api.test.ts
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Time: 0.248 s
💡 Unit vs. integration tests
Tests that complete with mocks are "unit tests"; tests that need the actual FastAPI server running are "integration tests." Right now they share a single describe block, but as things grow, splitting them into
unit/andintegration/folders makes them easier to manage.
What I Asked AI For
| Topic | What AI helped with |
|---|---|
| Spec clarification | Surfacing missing and unclear requirements |
| Design suggestion (Python) | Explaining why to split ItemCreate and Item
|
| Concept explanation (Python) | Explaining venv, pip, uvicorn, HTTPException vs Java/TS |
| 404 exit bug | Caught that case "2" had no try-catch, so errors reached main().catch() and exited |
| Error message branching | Suggested error.message.includes("404") to show "not found" |
| return vs throw | Suggested re-throwing with throw error instead of return on unexpected errors |
| Base URL collection | Suggested pulling the three hardcoded URLs into an API_ENDPOINT constant |
| Body simplification | Suggested simplifying to JSON.stringify(inputItem)
|
| interface syntax | Caught the trailing }; that should be }
|
Note: I found and fixed the 422 error (below) myself.
Where I Got Stuck
1. A 422 error on POST (the Content-Type header)
Situation: When adding an item (POST), the server returned 422 (Unprocessable Entity).
Root cause: My fetch POST didn't set the Content-Type: application/json header. Without it, FastAPI can't interpret the request body as JSON and returns 422.
// ❌ no header — the body can't be read as JSON → 422
await fetch(`${API_ENDPOINT}/items`, {
method: "POST",
body: JSON.stringify(inputItem),
});
// ✅ declare the Content-Type
await fetch(`${API_ENDPOINT}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inputItem),
});
Takeaway: I reached the cause on my own. A server and a client only agree to "exchange JSON" once that's declared via the header. I never thought about this while writing GET-only code.
2. The CLI exits entirely on a 404
Situation: Entering a non-existent ID exited the CLI with no error message at all.
Root cause: The exception thrown by getItemById wasn't caught inside the switch and propagated all the way to main().catch(). Since main().catch() is the last line of defense that ends the program, the loop couldn't resume and the CLI fell over.
// ❌ no catch in the case → the exception reaches main().catch() and exits
case "2": {
const item = await getItemById(parseInt(id)); // 404 throws → loop breaks → exit
console.log(item);
break;
}
I added try-catch inside case "2", showing "Item not found" for 404 and continuing the loop (code shown earlier).
Takeaway: Where you catch a thrown error decides whether the program continues or exits. The same exception leads to a different user experience depending on where you place the catch.
3. The difference between return and throw error
Situation: On unexpected errors, I first exited main with return. That quietly ended the program normally, and I couldn't tell what had happened.
Root cause and fix: return just ends main normally. Logging with console.error(error) and then re-throwing with throw error lets it reach main().catch() and end with process.exit(1) (abnormal exit).
// normal exit. no error detail remains
return;
// abnormal exit. delegate to main().catch() → process.exit(1)
console.error(error);
throw error;
Takeaway: This felt close to delegating an exception upward with Java's throws. Choosing deliberately between "swallow it" and "throw it up" matters.
What I Learned
async/await and fetch (GET / POST)
- POST specifies
method,headers, andbody. Thebodyis stringified withJSON.stringify(...) - Sending JSON to FastAPI requires
Content-Type: application/json(without it, 422) - For both GET and POST, you detect errors yourself with
!response.okandthrow
Error handling
| Topic | Key Takeaway |
|---|---|
| try-catch placement | Where you catch decides whether the program continues or exits |
error instanceof Error |
A caught error is unknown. Narrow the type before using .message
|
return vs throw error
|
return exits normally. throw error delegates to main().catch() for an abnormal exit. Close to Java's throws
|
Letting types simplify code
- Since the
ItemCreatetype is guaranteed to hold onlynameandprice,JSON.stringify(inputItem)sends it as-is. No need to expand the fields by hand
FastAPI / Python (first contact)
| Topic | Key Takeaway |
|---|---|
| Decorators |
@app.get / @app.post correspond to Spring's @GetMapping / @PostMapping
|
BaseModel |
Defines request/response data structures. Equivalent to a Java DTO |
HTTPException |
An exception with a status code. Equivalent to ResponseStatusException
|
| RequestDTO/ResponseDTO split |
ItemCreate (no id) and Item (with id) separate the responsibilities |
/docs |
Just visiting it auto-generates a Swagger UI |
Test design
- Tests that complete with mocks are unit tests; those needing a running server are integration tests
- A POST test can confirm the add by comparing the count (length) before and after
Reflection
I built this the same way as before: write the code myself and have AI review it. The big difference from last time was that I built the "other side" of the API myself.
Three things stood out:
A server and a client are connected by a "contract": The 422 error came down to declaring "this is JSON" via the Content-Type header before FastAPI would understand it. Building both sides myself made that boundary agreement tangible.
Exceptions are a design choice about "where to catch": The 404-exit incident showed me that try-catch placement directly shapes the user experience. The return vs throw error distinction also clicked, connecting back to my memory of Java's throws.
FastAPI looked like Spring: Decorators, DTOs, exception classes, auto-docs. It was rewarding to feel my past Java experience working as a foundation even in a new language.
Wrapping Up
This was my record of building a self-made FastAPI server and a TypeScript HTTP client CLI that calls it.
Progress since the previous projects:
- Implemented not just GET but POST with
fetch(Content-Type,body) - Found and fixed the cause of a 422 error myself
- Understood that try-catch placement changes behavior, and made a CLI that doesn't fall over on 404
- Used
returnandthrow errordeliberately - Built the server side with FastAPI too, understanding both ends of the API
Building both ends of an API once gave me both perspectives — "the server as seen from the client" and "the client as seen from the server." Next, I want to build on this setup and move toward more practical data operations and persistence.
Full learning logs are in each repository:
This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.
Top comments (0)