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:
async/await
fetch
type
to model the JSON shape)response.ok
keyof typeof
fetch
with jest.spyOn(global, "fetch")
toMatchObject
expect.any()
Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.
💡
Learning companionsI use
Claude Pro(design discussions and Q&A) andCursor 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 whysomething works before moving on
In this article, I clearly separate "what I implemented myself" from "what I asked AI for."
A CLI weather tool. Pick a city, or enter a latitude and longitude, and it shows the current weather, temperature, humidity, and wind speed.
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%
Weather data comes from the free Open-Meteo API (no API key required).
📦 Repository: https://github.com/uya0526-design/weather_api
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.
readline
and fetch
)The heart of the type design this time was how to express an API response as a type.
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.
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 realityAt first I defined
temperature_2m
and the other fields insidecurrent
asstring
. Looking at the actual API response, they're numbers, sonumber
is 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.
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 enumI originally went for
enum
, but I decided on my own that a "code → text" mapping reads more naturally as aconst
object than as anenum
. Anenum
is "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).
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.
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.
The highlight of testing this project was mocking fetch.
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.
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 afterEachI 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 intoafterEach
so 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
| 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 |
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.
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.
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.
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
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.)
await fetch(url)
inside an async function
lets you write async code top-to-bottom, sequentiallyfetch
does not throw on HTTP errors — you check response.ok
yourselfresponse.json()
extracts the response body as JSON| 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 |
| 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 |
|
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 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.
This was my record of building an external API tool (a weather CLI) as a TypeScript beginner.
Progress since the previous projects:
async/await
and fetch
type
(choosing number
/string
)keyof typeof
to leverage object keys as a typefetch
with jest.spyOn(global, "fetch")
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.