{"slug": "building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types", "title": "Building a Weather API CLI with TypeScript — async/await, fetch, and Response Types", "summary": "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.", "body_md": "This is my sixth article as a Java engineer learning TypeScript from scratch.\n\nIn my previous article, I built a Quiz CLI and learned about `enum`\n\n, tuple types, and mocking an entire module with `jest.mock()`\n\n. This time, I built a **Weather API CLI** and focused on:\n\n`async/await`\n\n`fetch`\n\n`type`\n\nto model the JSON shape)`response.ok`\n\n`keyof typeof`\n\n`fetch`\n\nwith `jest.spyOn(global, \"fetch\")`\n\n`toMatchObject`\n\n+ `expect.any()`\n\nSame as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.\n\n💡\n\nLearning companionsI use\n\nClaude Pro(design discussions and Q&A) andCursor Pro(coding support) as learning companions.My rules:\n\nI write all the code myself— I never ask AI to write code for me- AI helps with hints, spec clarification, and bug spotting\n- I make sure I understand\nwhysomething works before moving on\n\nIn this article, I clearly separate \"what I implemented myself\" from \"what I asked AI for.\"\n\nA CLI weather tool. Pick a city, or enter a latitude and longitude, and it shows the current weather, temperature, humidity, and wind speed.\n\n```\nWeather API Call CLI\n================================================\n1. Select a city and run\n2. Enter latitude and longitude and run\n3. Exit\nChoose: 1\n1. Sapporo\n2. Sendai\n3. Tokyo\n4. Nagoya\n5. Osaka\n6. Fukuoka\n7. Kagoshima\n8. Naha\nChoose a city: 3\nTokyo weather: Partly cloudy\nTokyo temperature: 20.4°C\nTokyo humidity: 80%\nTokyo wind speed: 3.1km/h\n================================================\n```\n\nWeather data comes from the free [Open-Meteo](https://open-meteo.com/) API (no API key required).\n\n📦 Repository: [https://github.com/uya0526-design/weather_api](https://github.com/uya0526-design/weather_api)\n\n```\nweather_api/\n├── src/\n│   ├── index.ts             # Entry point / CLI menu\n│   ├── api.ts               # API call logic\n│   ├── types.ts             # Type definitions\n│   └── __tests__/\n│       └── api.test.ts      # Unit tests\n├── jest.config.js\n├── tsconfig.json\n└── package.json\n```\n\nSame three-layer structure as the previous projects: `types.ts`\n\n(types) / `api.ts`\n\n(logic) / `index.ts`\n\n(UI). New this time: **communicating with an external API** is the main event.\n\n`readline`\n\nand `fetch`\n\n)The heart of the type design this time was **how to express an API response as a type**.\n\nI defined the list of selectable cities and their latitude, longitude, and timezone.\n\n``` js\n// List of selectable cities\nexport const cityList = [\n  \"Sapporo\", \"Sendai\", \"Tokyo\", \"Nagoya\",\n  \"Osaka\", \"Fukuoka\", \"Kagoshima\", \"Naha\",\n];\n\n// Latitude / longitude / timezone per city\nexport const cityCoordinates = {\n  \"Tokyo\": {\n    latitude: 35.68123,\n    longitude: 139.76712,\n    timezone: \"Asia/Tokyo\",\n  },\n  // ...\n};\n```\n\nThe quietly useful tool here is ** keyof typeof**. It lets me pull out just the keys of\n\n`cityCoordinates`\n\n(the city names) as a type.\n\n```\ntype CityName = keyof typeof cityCoordinates;\n// becomes a union type: \"Sapporo\" | \"Sendai\" | \"Tokyo\" | ...\n```\n\nIn Java I'd define the cities as an `enum`\n\n. In TypeScript I can reuse the keys of an object that already exists directly as a type — that felt fresh.\n\nThis was the main challenge. I called the API first, checked the actual response JSON, and modeled that shape directly with `type`\n\n.\n\n```\nexport type WeatherResponse = {\n  latitude: number;\n  longitude: number;\n  timezone: string;\n  timezone_abbreviation: string;\n  current_units: {\n    temperature_2m: string;\n    relative_humidity_2m: string;\n    weather_code: string;\n    wind_speed_10m: string;\n  };\n  current: {\n    time: string;\n    interval: number;\n    temperature_2m: number;\n    relative_humidity_2m: number;\n    weather_code: number;\n    wind_speed_10m: number;\n  };\n};\n```\n\n⚠️\n\nA type that didn't match realityAt first I defined\n\n`temperature_2m`\n\nand the other fields inside`current`\n\nas`string`\n\n. Looking at the actual API response, they're numbers, so`number`\n\nis 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.\n\nOpen-Meteo returns WMO (World Meteorological Organization) weather codes as numbers. I defined a mapping to convert them to readable text.\n\n``` js\nexport const WeatherCode = {\n  0: { name: \"Clear\" },\n  1: { name: \"Mainly clear\" },\n  2: { name: \"Partly cloudy\" },\n  3: { name: \"Cloudy\" },\n  45: { name: \"Fog\" },\n  // ...\n  95: { name: \"Thunderstorm\" },\n};\n```\n\n📝\n\nI first tried writing this as an enumI originally went for\n\n`enum`\n\n, but I decided on my own that a \"code → text\" mapping reads more naturally as a`const`\n\nobject than as an`enum`\n\n. An`enum`\n\nis \"a named set of constants,\" while an object is \"a key-to-value mapping\" — the difference in purpose started to feel intuitive.\n\nAt this point I had written the keys as the string `\"0\"`\n\n, which didn't line up with the numeric `weather_code`\n\nfrom the API, so I fixed them to the numeric key `0`\n\n(also an AI catch).\n\nThe `getWeather`\n\nfunction takes a latitude, longitude, and timezone, and calls the API.\n\n```\nexport async function getWeather(\n  latitude: number,\n  longitude: number,\n  timezone: string | undefined,\n): Promise<WeatherResponse> {\n  const params = new URLSearchParams({\n    latitude: String(latitude),\n    longitude: String(longitude),\n    current: \"temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m\",\n  });\n  if (timezone) {\n    params.append(\"timezone\", timezone);\n  }\n\n  const url = `https://api.open-meteo.com/v1/forecast?${params.toString()}`;\n  const response = await fetch(url);\n\n  if (!response.ok) {\n    throw new Error(`API error: ${response.status}`);\n  }\n\n  return await response.json();\n}\n```\n\nTwo key points.\n\n**1. Detect HTTP errors with response.ok**\n\n`fetch`\n\ndoes not throw on HTTP errors like 404 or 500 — `response.ok`\n\njust becomes `false`\n\n(this differs from how some Java HTTP clients behave). So I have to check `if (!response.ok)`\n\nmyself and throw.\n\n**2. Branch when timezone is undefined**\n\nIn the flow where you enter latitude/longitude directly, there may be no `timezone`\n\n, so I branch to leave it out of the query in that case.\n\nI adopted the `async/await`\n\nwrapping I learned in the previous Quiz project, from the start this time.\n\n```\nfunction askQuestion(prompt: string): Promise<string> {\n  return new Promise((resolve) => {\n    rl.question(prompt, (answer) => {\n      resolve(answer);\n    });\n  });\n}\n```\n\nThis lets me write `await askQuestion(...)`\n\ntop-to-bottom inside the `while (true)`\n\nmenu loop.\n\nIn the latitude/longitude input flow, I added validation with `isNaN`\n\nand a range check.\n\n``` js\nconst lat = Number(await askQuestion(\"Enter latitude: \"));\nif (isNaN(lat) || lat < -90 || lat > 90) {\n  console.log(\"Latitude must be a number between -90 and 90\");\n  continue;\n}\n```\n\nThe weather code is converted to text by looking it up in `WeatherCode`\n\n.\n\n``` js\nconst code = weather.current.weather_code;\nconst weatherName = WeatherCode[code as keyof typeof WeatherCode]?.name ?? \"Unknown\";\n```\n\nAnd rather than hardcoding the units, I pull them from the API response's `current_units`\n\n(another point improved from an AI catch).\n\n```\nconsole.log(`${city} temperature: ${weather.current.temperature_2m}${weather.current_units.temperature_2m}`);\nconsole.log(`${city} wind speed: ${weather.current.wind_speed_10m}${weather.current_units.wind_speed_10m}`);\n```\n\nFinally, `main().catch()`\n\ncatches any unexpected errors in one place.\n\nThe highlight of testing this project was **mocking fetch**.\n\n``` js\ntest(\"can fetch the weather for Tokyo\", async () => {\n  const result = await getWeather(35.68, 139.76, \"Asia/Tokyo\");\n  expect(result).toMatchObject({\n    latitude: expect.any(Number),\n    longitude: expect.any(Number),\n    current: {\n      temperature_2m: expect.any(Number),\n      weather_code: expect.any(Number),\n    },\n  });\n});\n```\n\nUsing `toMatchObject`\n\n+ `expect.any(Number)`\n\nverifies **not just that a field exists, but that its type matches** — all at once. I first wrote this with `toBeDefined()`\n\nonly, but that would pass even if the type was off, so I improved it.\n\nI can't conveniently produce a broken API for the error path, so I swap `fetch`\n\nfor a mock.\n\n``` js\ntest(\"throws when the API returns an error\", async () => {\n  jest.spyOn(global, \"fetch\").mockResolvedValue({\n    ok: false,\n    status: 500,\n  } as Response);\n\n  await expect(getWeather(35.68, 139.76, \"Asia/Tokyo\")).rejects.toThrow(\"API error\");\n});\n\nafterEach(() => {\n  jest.restoreAllMocks();\n});\n```\n\nLast time, `fs`\n\ncouldn't be patched with `jest.spyOn`\n\nand needed `jest.mock()`\n\n, but `global.fetch`\n\ncan be swapped with `jest.spyOn(global, \"fetch\")`\n\n. The lesson from last time — that which approach works depends on the target's property descriptor (`configurable`\n\n) — carried over directly.\n\n💡\n\nCleanup belongs in afterEachI first wrote\n\n`jest.restoreAllMocks()`\n\ninside the test body, but if the test fails it never reaches that line and the mock stays in place. I moved it into`afterEach`\n\nso cleanup runs whether the test passes or fails.\n\nTest results:\n\n```\n PASS  src/__tests__/api.test.ts\n\nTest Suites: 1 passed, 1 total\nTests:       2 passed, 2 total\nTime:        1.216 s\n```\n\n| Topic | What AI helped with |\n|---|---|\n| Spec clarification | Identified unclear and missing requirements up front |\n| Type fix | Caught numeric fields inside `current` typed as `string`\n|\n| Hardcoded units | Suggested reading units from `current_units` instead of fixing them to m/s |\n| Weather code key mismatch | Caught string key \"0\" not matching the numeric `weather_code`\n|\n| Unused import removal | Removed `WeatherResponse` that wasn't used in `index.ts`\n|\n| Test type verification | Improved from `toBeDefined()` to `toMatchObject` + `expect.any()`\n|\n| Mock cleanup | Suggested moving `restoreAllMocks()` into `afterEach`\n|\n| Peer dependency check | Showed how to use `npm info` with `peerDependencies` to check a package |\n\n**Situation:** Inside `current`\n\non `WeatherResponse`\n\n, I defined `temperature_2m`\n\nand others as `string`\n\n.\n\n**Root cause:** Looking at the actual API response, temperature, humidity, weather code, and wind speed are all **numbers**. The units (`°C`\n\n, `%`\n\n, etc.) live separately in `current_units`\n\nas strings — I was conflating the two.\n\n```\n// ❌ a number, but defined as string\ncurrent: {\n  temperature_2m: string;\n};\n\n// ✅ numbers are number\ncurrent: {\n  temperature_2m: number;\n};\n```\n\n**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.\n\n**Situation:** I wrote the wind speed unit as `\"m/s\"`\n\ndirectly in the code.\n\n**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\n\n`current_units`\n\nfield, using that is the right answer.\n\n```\n// ❌ unit fixed in code\nconsole.log(`wind speed: ${weather.current.wind_speed_10m}m/s`);\n\n// ✅ use the unit the API returns\nconsole.log(`wind speed: ${weather.current.wind_speed_10m}${weather.current_units.wind_speed_10m}`);\n```\n\n**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.\n\n**Situation:** I wrote the `WeatherCode`\n\nkeys as the strings `\"0\"`\n\nand `\"1\"`\n\n.\n\n**Root cause:** The API's `weather_code`\n\ncomes back as a **number**. When looking it up with `WeatherCode[weather_code]`\n\n, the keys need to be defined as numbers or the lookup won't work as intended.\n\n``` js\n// ❌ string keys\nexport const WeatherCode = {\n  \"0\": { name: \"Clear\" },\n};\n\n// ✅ numeric keys (match the numeric weather_code from the API)\nexport const WeatherCode = {\n  0: { name: \"Clear\" },\n};\n```\n\n**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.\n\nThis was less a sticking point and more a **turning point in how I learn**.\n\nDuring development, AI flagged that \"the combination of `jest ^30`\n\nand `ts-jest ^29`\n\nmay have a version mismatch.\" Rather than taking it at face value, I checked it myself with `npm info`\n\n.\n\n```\nnpm info ts-jest peerDependencies\n# result: jest: '^29.0.0 || ^30.0.0'\n```\n\n`ts-jest`\n\nsupports both jest 29 and 30, so in reality **there was no problem**.\n\n**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.\"\n\n(Side note: `npm install`\n\nbrings 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`\n\nagainst a package's `peerDependencies`\n\nwas a real gain.)\n\n`await fetch(url)`\n\ninside an `async function`\n\nlets you write async code top-to-bottom, sequentially`fetch`\n\ndoes not throw on HTTP errors — you check `response.ok`\n\nyourself`response.json()`\n\nextracts the response body as JSON| Topic | Key Takeaway |\n|---|---|\n| Response type definition | You can model each JSON field with `type` . Choosing `number` vs `string` matters |\n`keyof typeof` |\nExtracts an existing object's keys as a type. I auto-generated a union of city names |\n| Numeric vs string keys | Object keys differ as numbers vs strings. Match them to the lookup type |\n\n| Topic | Key Takeaway |\n|---|---|\n`jest.spyOn(global, \"fetch\")` |\nSwaps the global `fetch` for a mock |\n`mockResolvedValue` |\nCreates a mock that returns a Promise (for mocking async functions) |\n`rejects.toThrow()` |\nVerifies that an async operation throws |\n`toMatchObject` + `expect.any()`\n|\nVerifies structure and type at once — stricter than `toBeDefined()`\n|\nCleanup in `afterEach`\n|\nMocks reliably reset even when a test fails. Equivalent to Java's `@AfterEach`\n|\n\nI built this with the same style as before: write the code myself and have AI review it.\n\nThree things stood out:\n\n**Check type definitions against the real response:** Mixing up `string`\n\nand `number`\n\nwas 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.\"\n\n**A sense for avoiding hardcoding:** Pulling units from the API made me pause the next time I felt the urge to hardcode a value.\n\n**Use AI, but confirm it yourself in the end:** Verifying the version-mismatch flag with `npm info`\n\nmyself was my biggest gain this time. AI is convenient, but I want to keep the initiative on fact-checking.\n\nThis was my record of building an external API tool (a weather CLI) as a TypeScript beginner.\n\nProgress since the previous projects:\n\n`async/await`\n\nand `fetch`\n\n`type`\n\n(choosing `number`\n\n/`string`\n\n)`keyof typeof`\n\nto leverage object keys as a type`fetch`\n\nwith `jest.spyOn(global, \"fetch\")`\n\n`npm info`\n\nNext up: a simple HTTP client that calls my own API (FastAPI), going deeper on `async/await`\n\nand type definitions.\n\nFull learning log: [LEARNING_LOG.md](https://github.com/uya0526-design/weather_api/blob/main/LEARNING_LOG.md)\n\n*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.*", "url": "https://wpnews.pro/news/building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types", "canonical_source": "https://dev.to/uya0526design/building-a-weather-api-cli-with-typescript-asyncawait-fetch-and-response-types-3182", "published_at": "2026-06-06 01:32:45+00:00", "updated_at": "2026-06-06 02:12:23.452616+00:00", "lang": "en", "topics": ["ai-tools", "ai-products"], "entities": ["Claude Pro", "Cursor Pro", "TypeScript", "Java", "Weather API"], "alternates": {"html": "https://wpnews.pro/news/building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types", "markdown": "https://wpnews.pro/news/building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types.md", "text": "https://wpnews.pro/news/building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types.txt", "jsonld": "https://wpnews.pro/news/building-a-weather-api-cli-with-typescript-async-await-fetch-and-response-types.jsonld"}}