Introduction
This is my sixth article as a Java engineer learning TypeScript from scratch.
In my previous article, I built a Quiz CLI and learned about enum, tuple types, and mocking an entire module with jest.mock(). This time, I built a Weather API CLI and focused on:
- Calling an external API asynchronously with
async/awaitandfetch - Describing an API response with type definitions (
typeto model the JSON shape) - HTTP error handling with
response.ok - Extracting object key types with
keyof typeof - Swapping out
fetchwithjest.spyOn(global, "fetch") - Verifying structure and type at once with
toMatchObject+expect.any()
Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.
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
A CLI weather tool. Pick a city, or enter a latitude and longitude, and it shows the current weather, temperature, humidity, and wind speed.
Weather API Call CLI
================================================
1. Select a city and run
2. Enter latitude and longitude and run
3. Exit
Choose: 1
1. Sapporo
2. Sendai
3. Tokyo
4. Nagoya
5. Osaka
6. Fukuoka
7. Kagoshima
8. Naha
Choose a city: 3
Tokyo weather: Partly cloudy
Tokyo temperature: 20.4°C
Tokyo humidity: 80%
Tokyo wind speed: 3.1km/h
================================================
Weather data comes from the free Open-Meteo API (no API key required).
📦 Repository: https://github.com/uya0526-design/weather_api
Project Structure
weather_api/
├── src/
│ ├── index.ts # Entry point / CLI menu
│ ├── api.ts # API call logic
│ ├── types.ts # Type definitions
│ └── __tests__/
│ └── api.test.ts # Unit tests
├── jest.config.js
├── tsconfig.json
└── package.json
Same three-layer structure as the previous projects: types.ts (types) / api.ts (logic) / index.ts (UI). New this time: communicating with an external API is the main event.
Tech Stack
- TypeScript
- Node.js (
readlineandfetch) - Jest + ts-jest (unit testing)
- Open-Meteo API (weather data)
What I Implemented Myself
types.ts — API Response Types
The heart of the type design this time was how to express an API response as a type.
First, define the "city data"
I defined the list of selectable cities and their latitude, longitude, and timezone.
// List of selectable cities
export const cityList = [
"Sapporo", "Sendai", "Tokyo", "Nagoya",
"Osaka", "Fukuoka", "Kagoshima", "Naha",
];
// Latitude / longitude / timezone per city
export const cityCoordinates = {
"Tokyo": {
latitude: 35.68123,
longitude: 139.76712,
timezone: "Asia/Tokyo",
},
// ...
};
The quietly useful tool here is keyof typeof. It lets me pull out just the keys of cityCoordinates (the city names) as a type.
type CityName = keyof typeof cityCoordinates;
// becomes a union type: "Sapporo" | "Sendai" | "Tokyo" | ...
In Java I'd define the cities as an enum. In TypeScript I can reuse the keys of an object that already exists directly as a type — that felt fresh.
Define the API response type
This was the main challenge. I called the API first, checked the actual response JSON, and modeled that shape directly with type.
export type WeatherResponse = {
latitude: number;
longitude: number;
timezone: string;
timezone_abbreviation: string;
current_units: {
temperature_2m: string;
relative_humidity_2m: string;
weather_code: string;
wind_speed_10m: string;
};
current: {
time: string;
interval: number;
temperature_2m: number;
relative_humidity_2m: number;
weather_code: number;
wind_speed_10m: number;
};
};
⚠️ A type that didn't match reality
At first I defined
temperature_2mand the other fields insidecurrentasstring. Looking at the actual API response, they're numbers, sonumberis correct — and AI caught this for me (more below). A wrong type definition leads to runtime errors, so I felt firsthand how important it is to check the type against the real response.
Convert weather codes to text
Open-Meteo returns WMO (World Meteorological Organization) weather codes as numbers. I defined a mapping to convert them to readable text.
export const WeatherCode = {
0: { name: "Clear" },
1: { name: "Mainly clear" },
2: { name: "Partly cloudy" },
3: { name: "Cloudy" },
45: { name: "Fog" },
// ...
95: { name: "Thunderstorm" },
};
📝 I first tried writing this as an enum
I originally went for
enum, but I decided on my own that a "code → text" mapping reads more naturally as aconstobject than as anenum. Anenumis "a named set of constants," while an object is "a key-to-value mapping" — the difference in purpose started to feel intuitive.
At this point I had written the keys as the string "0", which didn't line up with the numeric weather_code from the API, so I fixed them to the numeric key 0 (also an AI catch).
api.ts — Async API Calls with fetch
The getWeather function takes a latitude, longitude, and timezone, and calls the API.
export async function getWeather(
latitude: number,
longitude: number,
timezone: string | undefined,
): Promise<WeatherResponse> {
const params = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m",
});
if (timezone) {
params.append("timezone", timezone);
}
const url = `https://api.open-meteo.com/v1/forecast?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
}
Two key points.
1. Detect HTTP errors with response.ok
fetch does not throw on HTTP errors like 404 or 500 — response.ok just becomes false (this differs from how some Java HTTP clients behave). So I have to check if (!response.ok) myself and throw.
2. Branch when timezone is undefined
In the flow where you enter latitude/longitude directly, there may be no timezone, so I branch to leave it out of the query in that case.
index.ts — Wrapping readline in a Promise
I adopted the async/await wrapping I learned in the previous Quiz project, from the start this time.
function askQuestion(prompt: string): Promise<string> {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
}
This lets me write await askQuestion(...) top-to-bottom inside the while (true) menu loop.
In the latitude/longitude input flow, I added validation with isNaN and a range check.
const lat = Number(await askQuestion("Enter latitude: "));
if (isNaN(lat) || lat < -90 || lat > 90) {
console.log("Latitude must be a number between -90 and 90");
continue;
}
The weather code is converted to text by looking it up in WeatherCode.
const code = weather.current.weather_code;
const weatherName = WeatherCode[code as keyof typeof WeatherCode]?.name ?? "Unknown";
And rather than hardcoding the units, I pull them from the API response's current_units (another point improved from an AI catch).
console.log(`${city} temperature: ${weather.current.temperature_2m}${weather.current_units.temperature_2m}`);
console.log(`${city} wind speed: ${weather.current.wind_speed_10m}${weather.current_units.wind_speed_10m}`);
Finally, main().catch() catches any unexpected errors in one place.
api.test.ts — Mocking fetch
The highlight of testing this project was mocking fetch.
Success case: hit the real API and check
test("can fetch the weather for Tokyo", async () => {
const result = await getWeather(35.68, 139.76, "Asia/Tokyo");
expect(result).toMatchObject({
latitude: expect.any(Number),
longitude: expect.any(Number),
current: {
temperature_2m: expect.any(Number),
weather_code: expect.any(Number),
},
});
});
Using toMatchObject + expect.any(Number) verifies not just that a field exists, but that its type matches — all at once. I first wrote this with toBeDefined() only, but that would pass even if the type was off, so I improved it.
Failure case: swap fetch for a mock
I can't conveniently produce a broken API for the error path, so I swap fetch for a mock.
test("throws when the API returns an error", async () => {
jest.spyOn(global, "fetch").mockResolvedValue({
ok: false,
status: 500,
} as Response);
await expect(getWeather(35.68, 139.76, "Asia/Tokyo")).rejects.toThrow("API error");
});
afterEach(() => {
jest.restoreAllMocks();
});
Last time, fs couldn't be patched with jest.spyOn and needed jest.mock(), but global.fetch can be swapped with jest.spyOn(global, "fetch"). The lesson from last time — that which approach works depends on the target's property descriptor (configurable) — carried over directly.
💡 Cleanup belongs in afterEach
I first wrote
jest.restoreAllMocks()inside the test body, but if the test fails it never reaches that line and the mock stays in place. I moved it intoafterEachso cleanup runs whether the test passes or fails.
Test results:
PASS src/__tests__/api.test.ts
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 1.216 s
What I Asked AI For
| Topic | What AI helped with |
|---|---|
| Spec clarification | Identified unclear and missing requirements up front |
| Type fix | Caught numeric fields inside current typed as string
|
| Hardcoded units | Suggested reading units from current_units instead of fixing them to m/s |
| Weather code key mismatch | Caught string key "0" not matching the numeric weather_code
|
| Unused import removal | Removed WeatherResponse that wasn't used in index.ts
|
| Test type verification | Improved from toBeDefined() to toMatchObject + expect.any()
|
| Mock cleanup | Suggested moving restoreAllMocks() into afterEach
|
| Peer dependency check | Showed how to use npm info with peerDependencies to check a package |
Where I Got Stuck
1. I typed numeric response fields as string
Situation: Inside current on WeatherResponse, I defined temperature_2m and others as string.
Root cause: Looking at the actual API response, temperature, humidity, weather code, and wind speed are all numbers. The units (°C, %, etc.) live separately in current_units as strings — I was conflating the two.
// ❌ a number, but defined as string
current: {
temperature_2m: string;
};
// ✅ numbers are number
current: {
temperature_2m: number;
};
Takeaway: Check type definitions against the real API response. Deciding "this feels string-ish" leads to a runtime mismatch — an obvious lesson I learned the hard way.
2. I hardcoded the units
Situation: I wrote the wind speed unit as "m/s" directly in the code.
Root cause: Open-Meteo actually returns wind speed in km/h. Hardcoding it would make the display a lie. And since the API returns the units in a current_units field, using that is the right answer.
// ❌ unit fixed in code
console.log(`wind speed: ${weather.current.wind_speed_10m}m/s`);
// ✅ use the unit the API returns
console.log(`wind speed: ${weather.current.wind_speed_10m}${weather.current_units.wind_speed_10m}`);
Takeaway: Don't hardcode units or config values — pulling them from the data source is more resilient to spec changes. I aligned temperature and humidity to the same approach.
3. The weather code keys didn't match because they were strings
Situation: I wrote the WeatherCode keys as the strings "0" and "1".
Root cause: The API's weather_code comes back as a number. When looking it up with WeatherCode[weather_code], the keys need to be defined as numbers or the lookup won't work as intended.
// ❌ string keys
export const WeatherCode = {
"0": { name: "Clear" },
};
// ✅ numeric keys (match the numeric weather_code from the API)
export const WeatherCode = {
0: { name: "Clear" },
};
Takeaway: Match "the key type" to "the type you look it up with." Object keys can look similar but are different things as numbers vs. strings — a good reminder.
4. AI's suggestions aren't always correct
This was less a sticking point and more a turning point in how I learn.
During development, AI flagged that "the combination of jest ^30 and ts-jest ^29 may have a version mismatch." Rather than taking it at face value, I checked it myself with npm info.
npm info ts-jest peerDependencies
# result: jest: '^29.0.0 || ^30.0.0'
ts-jest supports both jest 29 and 30, so in reality there was no problem.
Takeaway: AI suggestions are just hints. When I can confirm a fact with a command, I confirm it myself. I want to build the habit of "verify and then decide" rather than "fix it because AI said so."
(Side note: npm install brings each package to its latest version independently, so combined versions can drift. But npm installs them anyway without erroring, which makes it easy to miss — learning that I can check ahead of time by running npm info against a package's peerDependencies was a real gain.)
What I Learned
async/await and fetch
- Writing
await fetch(url)inside anasync functionlets you write async code top-to-bottom, sequentially -
fetchdoes not throw on HTTP errors — you checkresponse.okyourself -
response.json()extracts the response body as JSON - Some Java HTTP clients throw depending on the status code, so this needed a mental shift
TypeScript Types
| Topic | Key Takeaway |
|---|---|
| Response type definition | You can model each JSON field with type. Choosing number vs string matters |
keyof typeof |
Extracts an existing object's keys as a type. I auto-generated a union of city names |
| Numeric vs string keys | Object keys differ as numbers vs strings. Match them to the lookup type |
Jest Mocking
| Topic | Key Takeaway |
|---|---|
jest.spyOn(global, "fetch") |
Swaps the global fetch for a mock |
mockResolvedValue |
Creates a mock that returns a Promise (for mocking async functions) |
rejects.toThrow() |
Verifies that an async operation throws |
toMatchObject + expect.any()
|
Verifies structure and type at once — stricter than toBeDefined()
|
Cleanup in afterEach
|
Mocks reliably reset even when a test fails. Equivalent to Java's @AfterEach
|
Design
- Don't hardcode units and config values — pulling them from the data source is more resilient to spec changes
- Remove unused imports (readability and maintainability)
- Tests should be written with "what am I verifying" in mind, not just "does it run"
- Verify AI's suggestions with commands — don't take them at face value
Reflection
I built this with the same style as before: write the code myself and have AI review it.
Three things stood out:
Check type definitions against the real response: Mixing up string and number was a mistake the actual JSON would have prevented. "Look at the real thing, then write the type" is more reliable than "imagine the type first."
A sense for avoiding hardcoding: Pulling units from the API made me pause the next time I felt the urge to hardcode a value.
Use AI, but confirm it yourself in the end: Verifying the version-mismatch flag with npm info myself was my biggest gain this time. AI is convenient, but I want to keep the initiative on fact-checking.
Wrapping Up
This was my record of building an external API tool (a weather CLI) as a TypeScript beginner.
Progress since the previous projects:
- Called an external API asynchronously with
async/awaitandfetch - Modeled the API response structure accurately with
type(choosingnumber/string) - Used
keyof typeofto leverage object keys as a type - Mocked
fetchwithjest.spyOn(global, "fetch") - Built the habit of verifying AI's suggestions myself with
npm info
Next up: a simple HTTP client that calls my own API (FastAPI), going deeper on async/await and type definitions.
Full learning log: LEARNING_LOG.md
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)