Skip to content

How to Type Home Assistant REST and WebSocket Events

Type Home Assistant entity states in TypeScript: REST and WebSocket payloads, per-domain unions, and Zod validation at the API boundary.

· · 5 min read
A raw Home Assistant entity payload narrowing into three typed TypeScript reading states

Quick Take

Home Assistant gives you a real API on your own network, with no billing page and no rate limit, which makes it the best practice target for typed API work I've found. The catch is that every entity state arrives as a string, and the interesting data hides in an untyped attributes bag.

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?

Quick take: Home Assistant entity states arrive as strings with an untyped attributes bag. Model them as a discriminated union keyed on the entity domain (light., sensor., climate.), validate at the boundary with Zod, and treat unavailable and unknown as first-class states rather than errors. Seed with one REST call to /api/states, then subscribe over WebSocket for updates.

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.

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.

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

Handling unavailable Without Poisoning Every Call Site

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 it's a failure I've shipped more than once. Wiring those three states into a live view is the next problem, and that's a real-time React dashboard.

A raw Home Assistant entity payload narrowing into three typed reading states: value, unavailable, and unknown
One payload, three states the compiler can force you to handle.

Validating 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:

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.

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

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

Frequently Asked Questions

How do I authenticate against the Home Assistant API?
With a long-lived access token, created from your user profile page in the Home Assistant UI, at the bottom under Security. It's a JWT that doesn't expire until you revoke it. For the REST API you send it as a normal Authorization: Bearer header. For the WebSocket API you don't send a header at all, the server sends you an auth_required message on connect and you reply with an auth message containing the token. Treat it like a password, it grants full control of the instance, so keep it out of client-side bundles and out of version control.
Why is every Home Assistant state a string?
Because the state machine stores states as strings by design, with a 255-character limit, and anything richer lives in the attributes object. A temperature sensor reporting 21.5 gives you the string "21.5", not the number. That's why parsing belongs at the boundary: convert once when the payload arrives, validate it there, and let the rest of your code work with real numbers. Special values are strings too, "unavailable" and "unknown" are states, not errors, and code that skips them will crash the first time a device drops off the network.
Should I use the REST API or the WebSocket API?
REST for one-off reads and for calling services, WebSocket for anything live. The REST endpoint /api/states returns every entity in one response, which is perfect for an initial load or a script. The WebSocket API lets you subscribe to state_changed events and receive a push the moment anything changes, which is the only sane way to build a dashboard. Most real applications use both: one REST call to seed the state, then a WebSocket subscription to keep it current.
Do I need Zod, or are TypeScript types enough?
Types alone are a promise, not a check. TypeScript erases at compile time, so an interface describing a payload does nothing when the payload arrives malformed at runtime, and Home Assistant payloads change shape between versions and across integrations. Validating at the boundary with Zod turns a confusing downstream crash into a clear error at the point of entry. Inside your own code, after validation, plain inferred types are fine.