Every API tutorial hits the same wall: the interesting examples need a paid key, so they get replaced with a mock server and the reader learns nothing about real payloads. Home Assistant fixes that. It runs on hardware you own, exposes a documented REST and WebSocket API, and produces genuinely messy real-world data. What does a properly typed client for it look like?
What Does a Home Assistant State Actually Look Like?
Ask /api/states for a single light and you get this:
{
"entity_id": "light.kitchen_ceiling",
"state": "on",
"attributes": {
"brightness": 204,
"color_temp_kelvin": 3000,
"supported_color_modes": ["color_temp"],
"friendly_name": "Kitchen Ceiling"
},
"last_changed": "2026-09-08T19:42:11.883012+00:00",
"last_updated": "2026-09-08T19:42:11.883012+00:00",
"context": { "id": "01J7...", "parent_id": null, "user_id": null }
}
The envelope is identical for every entity in the system. The attributes object is where it stops being identical, a light has brightness, a thermostat has current_temperature and hvac_action, a plain sensor has unit_of_measurement. That's the whole modelling problem in one object.
According to the Home Assistant developer docs, an entity's state can hold anything as long as it's a string, with non-string values converted, and attributes is a dictionary limited to JSON-serializable data. Authentication differs by transport: the wants an Authorization: Bearer header on every call, while the opens with an auth_required message from the server, answered by an auth message carrying the token and confirmed by auth_ok. Source: .
Two details bite people immediately. First, state is always a string, even for numeric sensors, because the state machine caps it at 255 characters and stores nothing else. Second, "unavailable" and "unknown" are legitimate values for any entity at any time. A device losing power produces "unavailable", not an HTTP error, and code that assumes otherwise breaks the first time somebody unplugs something.
How Do You Model the Envelope?
Start with the shared shape, then narrow per domain:
type EntityDomain = "light" | "sensor" | "climate" | "switch" | "binary_sensor";
interface BaseState<D extends EntityDomain, A> {
entity_id: `${D}.${string}`;
state: string;
attributes: A & { friendly_name?: string };
last_changed: string;
last_updated: string;
}
interface LightAttributes {
brightness?: number;
color_temp_kelvin?: number;
supported_color_modes?: string[];
}
interface SensorAttributes {
unit_of_measurement?: string;
device_class?: "temperature" | "humidity" | "power" | "energy";
state_class?: "measurement" | "total" | "total_increasing";
}
type LightState = BaseState<"light", LightAttributes>;
type SensorState = BaseState<"sensor", SensorAttributes>;
type AnyEntityState = LightState | SensorState;
The template literal type on entity_id is doing real work here. Because light.kitchen_ceiling is assignable to `light.${string}` and nothing else in the union matches it, a plain string check narrows the type:
function isLight(s: AnyEntityState): s is LightState {
return s.entity_id.startsWith("light.");
}
No discriminant field required, and no cast. The entity ID already carries the tag, which is the small elegance that makes this API pleasant to type. I'd lean on it rather than bolt on a type field of my own, because a tag you must keep in sync with the ID is the interface-plus-validator drift problem in miniature.
How Do You Handle unavailable Without Poisoning Call Sites?
The temptation is to type numeric sensors as number and deal with "unavailable" somewhere else. Resist it. Model the three cases explicitly and parse once:
type Reading =
| { kind: "value"; value: number; unit?: string }
| { kind: "unavailable" }
| { kind: "unknown" };
function readNumeric(s: SensorState): Reading {
if (s.state === "unavailable") {
return { kind: "unavailable" };
}
if (s.state === "unknown") {
return { kind: "unknown" };
}
const value = Number.parseFloat(s.state);
if (Number.isNaN(value)) {
return { kind: "unknown" };
}
return { kind: "value", value, unit: s.attributes.unit_of_measurement };
}
Now the compiler forces every consumer to decide what a missing reading looks like on screen, which is exactly the decision a dashboard has to make anyway. A gauge showing NaN because somebody's Zigbee sensor dropped is the failure this prevents, and I'd rather the compiler catch it than a person staring at a dashboard. Wiring those three states into a live view is the next problem, and that's a real-time React dashboard.
How Do You Validate at the Boundary With Zod?
Types describe what you expect. They don't check it. Home Assistant payloads shift between releases and vary by integration, so validate where data enters. If you already sketched the shapes as TypeScript interfaces, convert them to Zod schemas to skip transcribing each one by hand:
import { z } from "zod";
const baseState = z.object({
entity_id: z.string().includes("."),
state: z.string().max(255),
attributes: z.record(z.unknown()),
last_changed: z.string().datetime({ offset: true }),
last_updated: z.string().datetime({ offset: true }),
});
const sensorState = baseState.extend({
entity_id: z.string().startsWith("sensor."),
attributes: z.object({
unit_of_measurement: z.string().optional(),
device_class: z.enum(["temperature", "humidity", "power", "energy"]).optional(),
}).passthrough(),
});
export type SensorStateParsed = z.infer<typeof sensorState>;
.passthrough() matters more than it looks. Integrations add attributes freely, and a strict schema that rejects unknown keys will fail on somebody else's install for no good reason. Validate the fields you rely on, let the rest through untouched. My bias is to check five fields tightly rather than forty loosely: I'd take a schema that misses an odd attribute over one that throws on a stranger's install.
How Do You Subscribe Over WebSocket?
REST gives you a snapshot. For anything live you want the WebSocket API, which has an unusual handshake, the server speaks first:
const socket = new WebSocket("ws://homeassistant.local:8123/api/websocket");
let id = 1;
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data as string);
if (msg.type === "auth_required") {
socket.send(JSON.stringify({ type: "auth", access_token: TOKEN }));
return;
}
if (msg.type === "auth_ok") {
socket.send(JSON.stringify({
id: id++,
type: "subscribe_events",
event_type: "state_changed",
}));
return;
}
if (msg.type === "event") {
const { entity_id, new_state } = msg.event.data;
console.log(entity_id, new_state?.state);
}
});
Every command you send needs a monotonically increasing id, and the reply carries the same one, so a small Map<number, PendingCommand> is enough to correlate responses. Reconnection is the part worth taking seriously: Home Assistant restarts on every configuration reload, so your client will lose the socket regularly. Exponential backoff plus a fresh /api/states fetch after each reconnect keeps state consistent, because events that fired while you were disconnected are simply gone.
How Do You Get an Instance to Point This At?
All of this assumes a running Home Assistant with a token. If you don't have one, the install is genuinely an evening's work on a Raspberry Pi or in Docker, and the Home Assistant setup guide walks the whole path from a blank SD card to a first automation. Create the long-lived token from your profile page afterwards, and keep it server-side, it's a full-control credential and it belongs in an environment variable, never in a client bundle.
The payoff for the effort is a test target that behaves like production APIs do, with intermittent devices, inconsistent attribute shapes, and states that genuinely change while you're watching. Mock servers never taught me anything about reconnection. A Zigbee sensor going dark behind a fridge taught me in one evening.