cd /news/ai-tools/building-a-weather-api-cli-with-type… · home topics ai-tools article
[ARTICLE · art-23070] src=dev.to pub= topic=ai-tools verified=true sentiment=↑ positive

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

A Java engineer learning TypeScript built a Weather API CLI that retrieves current weather data from the Open-Meteo API using async/await and fetch. The project demonstrates modeling API response shapes with TypeScript types, including the use of `keyof typeof` to derive city name types from an existing coordinates object. The developer implemented unit tests with `jest.spyOn` to mock the fetch function and validate API response handling.

read12 min publishedJun 6, 2026

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.

── more in #ai-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-a-weather-a…] indexed:0 read:12min 2026-06-06 ·