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

> Source: <https://dev.to/uya0526design/building-a-weather-api-cli-with-typescript-asyncawait-fetch-and-response-types-3182>
> Published: 2026-06-06 01:32:45+00:00

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.

```
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](https://open-meteo.com/) API (no API key required).

📦 Repository: [https://github.com/uya0526-design/weather_api](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.

``` js
// 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 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.

Open-Meteo returns WMO (World Meteorological Organization) weather codes as numbers. I defined a mapping to convert them to readable text.

``` js
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 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).

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.

``` js
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`

.

``` js
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**.

``` js
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.

``` js
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 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
```

| 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.

``` js
// ❌ 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
# 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.)

`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| 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 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.

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 type`fetch`

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](https://github.com/uya0526-design/weather_api/blob/main/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.*
