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.

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

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.

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

Share this Post on X Bluesky

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.

A raw Home Assistant state string parsed once into three typed readings: value, unavailable and unknown
One payload, three states the compiler can force you to handle.
Embed this graphic

Want this on your own site? Paste the code below. It keeps the credit and links back to this article.

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.

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.