@truewire/weather-gov
v0.1.0
Published
Typed, validated TypeScript client for the US National Weather Service API, generated by Truewire.
Maintainers
Readme
weather.gov, typed — TypeScript
A typed, validated TypeScript client for the US National Weather Service API, generated by Truewire from the same spec as the Python client and proven against the same recordings.
No account, no key, no quota. The service asks one thing of a caller: say who you are in User-Agent. That is the contact option, and it is required.
Install
npm install @truewire/weather-govNode 20 or newer, or any runtime with fetch and URLSearchParams — the transport is fetch and nothing else, so Deno, Bun and the browser work unchanged. api.weather.gov sends Access-Control-Allow-Origin: *, so a browser is a real target here rather than a theoretical one.
Use
Almost everything in this API is addressed by grid cell, not by coordinates, so a caller starts at points.getPoint and uses what it returns:
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
const point = await client.points.getPoint({ latitude: 47.6062, longitude: -122.3321 })
console.log(point.gridId, point.gridX, point.gridY, point.timeZone)
const forecast = await client.forecast.getForecast({
office: point.gridId,
grid_x: point.gridX,
grid_y: point.gridY,
})
for (const period of forecast.periods.slice(0, 3)) {
console.log(period.name, `${period.temperature}°${period.temperatureUnit}`, period.shortForecast)
}SEW 125 68 America/Los_Angeles
Tonight 56°F Mostly Clear
Wednesday 74°F Sunny
Wednesday Night 57°F Partly CloudyLifecycle: there isn't one
Weather.new(...) returns a client and that is the whole of it. There is nothing to close, no await using, no dispose: the transport is fetch and a base URL, and the client owns no connection, no pool and no socket. Build one at module scope and keep it, or build one per request; neither leaks anything.
That is a property of this API rather than of Truewire — the Bluesky client holds WebSocket connections to the firehose and does implement AsyncDisposable, because there it has something to give back.
AbortSignal is per call, for the same reason: there is no client-level cancellation when there is no client-level resource.
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
const timeout = AbortSignal.timeout(5_000)
const alerts = await client.alerts.getActiveAlerts(
{ status: ['actual'], severity: ['Severe'] },
{ signal: timeout },
)
console.log(alerts.features.length, 'severe alerts in effect')Every measurement carries its unit
This API never sends a bare number. A temperature is { unitCode: 'wmoUnit:degC', value: 13, qualityControl: 'V' }, and value is null wherever the measurement is missing rather than zero — an airport station reports windGust only when there were gusts. value is typed number | null, so the null is impossible to forget:
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
const now = await client.stations.getLatestObservation({ station_id: 'KSEA' })
console.log(now.stationName, 'at', now.timestamp.toISOString())
console.log(' temperature', now.temperature.value, now.temperature.unitCode)
const gust = now.windGust?.value
console.log(' gusting to', gust ?? '(not gusting)')
for (const layer of now.cloudLayers) {
console.log(' ', layer.amount, 'at', layer.base.value, 'm')
}Note now.timestamp.toISOString(): a wire timestamp arrives as a Date, not as the string it travelled as. The spec declares the format and the codec does the conversion, in both directions — a start passed as a Date is rendered back to the ISO 8601 the service parses.
The wrapper is gone where it carried nothing
Most of this API answers in GeoJSON. For a forecast, the Feature wrapper's geometry is the outline of the grid cell you just named, and its properties is the forecast — so those endpoints declare an envelope and the client hands back the forecast. Endpoints whose geometry is real data, like an alert's polygon, are returned whole:
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
// Enveloped: `.periods`, not `.properties.periods`.
const forecast = await client.forecast.getForecast({ office: 'SEW', grid_x: 125, grid_y: 68 })
console.log(forecast.periods.length, 'periods')
// Not enveloped: the alert's polygon is data you asked for.
const alerts = await client.alerts.getActiveAlerts({ status: ['actual'], severity: ['Severe'] })
for (const feature of alerts.features.slice(0, 5)) {
const where = feature.geometry ? feature.geometry.type : 'zones only'
console.log(feature.properties.event, '—', feature.properties.areaDesc.slice(0, 40), `(${where})`)
}Filters that disagree about capitalisation
status takes actual; severity, urgency and certainty take Severe, Immediate, Likely. A client that lower-cased all four uniformly would get a 400 from three of them. The enumerations here come from the service's own error replies, so the mistake is a type error rather than a round trip:
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
await client.alerts.getActiveAlerts({
status: ['actual'],
// @ts-expect-error -- the service spells this one `Severe`, and the type says so.
severity: ['severe'],
})What the types cannot catch, the error mapping can:
import { BadRequest } from '@truewire/core'
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
try {
// A zone code is `WAZ315`-shaped, constrained by a regex rather than an enumeration --
// so this one is only caught on the wire.
await client.alerts.getActiveAlerts({ zone: ['NOPE'] })
} catch (error) {
if (error instanceof BadRequest) console.log(error.message)
}GET /alerts/active: HTTP 400: query.zone[0]: Does not match the regex pattern
^(A[KLMNRSZ]|C[AOT]|D[CE]|...)[CZ]\d{3}$Turning validation off
Responses are validated against their declared shapes by default. A call can opt out, and the return type becomes unknown rather than a lie:
import { Weather } from '@truewire/weather-gov'
const client = Weather.new({ contact: '[email protected]' })
const raw: unknown = await client.forecast.getGridData(
{ office: 'SEW', grid_x: 125, grid_y: 68 },
{ validate: false },
)
console.log(typeof raw)Passing validate: false to Weather.new makes that the default for the whole client, without changing any return type: a client-level default cannot make getForecast return unknown for one caller and a GridpointForecast for another.
What is covered
The same eleven endpoints as the Python client, in the same six groups, from the same spec: points, forecast, stations, alerts, offices, products. The method names are camelCase here and snake_case there; nothing else differs.
Tests
yarn test replays every recording in spec/ through this client against truewire mock, which serves them over real HTTP. Nothing in the tests touches the network. The assertions mirror packages/python/test/test_recordings.py one for one, so a divergence between the two clients is a test failure rather than a discovery months later.
Every code block on this page is compiled against the package by yarn test, so a snippet here cannot rot past the client.
Generated by Truewire
The endpoint classes, the types and the codecs are generated by truewire generate typescript and committed. The core — the transport, the User-Agent, the envelope, the error mapping — is written by hand and never touched by regeneration.
Generated code imports nothing from this package: it names the contract interfaces (HttpEndpoint<DefaultMeta>) and receives an object that satisfies them structurally. That is why Weather.new lives on a subclass in core/client.ts rather than on the generated class.
License
MIT.
