DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building a Weather API CLI with TypeScript — async/await, fetch, and Response Types

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/await and fetch
  • Describing an API response with type definitions (type to model the JSON shape)
  • HTTP error handling with response.ok
  • Extracting object key types with keyof typeof
  • Swapping out fetch with jest.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
================================================
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (readline and fetch)
  • 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",
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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" | ...
Enter fullscreen mode Exit fullscreen mode

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;
  };
};
Enter fullscreen mode Exit fullscreen mode

⚠️ A type that didn't match reality

At first I defined temperature_2m and the other fields inside current as string. Looking at the actual API response, they're numbers, so number 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.

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" },
};
Enter fullscreen mode Exit fullscreen mode

📝 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 a const object than as an enum. An enum 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).


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();
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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),
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 into afterEach 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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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" },
};
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 an async function lets you write async code top-to-bottom, sequentially
  • fetch does not throw on HTTP errors — you check response.ok yourself
  • 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:

  1. Called an external API asynchronously with async/await and fetch
  2. Modeled the API response structure accurately with type (choosing number/string)
  3. Used keyof typeof to leverage object keys as a type
  4. Mocked fetch with jest.spyOn(global, "fetch")
  5. 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)