---
title: "Your first Hem module"
description: "Build a small Hem module end to end: write a variant, read the TypeScript it emits, meet a compiler error, and get a test passing."
image: "https://hem.run/social/hem.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://hem.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Your first Hem module

In this tutorial we will write a Hem module, read the TypeScript it compiles into, deliberately break it to see what the compiler says, fix it, and finish with a passing test. It takes about twenty minutes.

We assume you know TypeScript and have Node 22.18 or newer installed. We will not use React or Effect.

Hem's packages are not published to npm yet. You can build local compatibility-preview tarballs in a checkout, then create this tutorial project **outside** that checkout. The starter copies the packed Hem packages into the new project; it does not copy or link the workspace's dependencies.

## Step 1: create the project

In a Hem checkout, install its frozen development dependencies and pack the preview. This setup needs pnpm, npm (included with Node), and a local `tar` executable (standard on macOS and Linux):

```sh
pnpm install --frozen-lockfile
node scripts/package-preview.ts
```

The command prints `Built 9 candidate tarballs: /.../hem-preview-artifacts-...`. Use that printed directory's `artifacts.json` in the next command (replace `/path/to/hem-preview-artifacts` with it):

```sh
node scripts/create-preview-app.mjs /path/to/hem-preview-artifacts/artifacts.json ../first-hem-app
cd ../first-hem-app
npm install --ignore-scripts --no-audit --no-fund
```

The destination must be absent or empty and outside the Hem checkout. Creation does not install anything. The starter copies only the CLI, Vite plugin, and their transitive Hem dependencies from the validated nine-package preview. The explicit `npm install` command installs those seven Hem packages from the project's `.hem-preview/*.tgz` files. Keep those archives with the project. This core tutorial does not install Effect, the Hem standard library, or the language server. TypeScript, Vite, Vitest, and their dependencies still need the npm registry or your npm cache; this is not a fully offline dependency bundle. With a populated cache, you can add `--offline` to the install command.

These are local compatibility previews, not npm releases or a published support contract. Node 22.18 or newer is required. The repository's `node scripts/package-preview.ts --verify` command separately checks its packed packages in a fresh consumer.

The starter creates `src/payment.hem`, its test, `vite.config.ts`, `tsconfig.json`, and a package manifest with pinned dependencies. Its scripts are:

```json
{
  "name": "first-hem-app",
  "private": true,
  "type": "module",
  "scripts": {
"build": "hem build",
"check": "hem check",
"test": "vitest run"
  }
}
```

The generated `tsconfig.json` contains the following configuration. Hem has no config file of its own, so this is where the project is defined:

```json
{
  "compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"module": "preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"target": "es2023",
"lib": ["es2023"],
"noEmit": true
  },
  "include": ["src/**/*.hem", "src/**/*.ts"]
}
```

Notice that `include` covers `.hem` files. Hem refuses to compile a file the project does not claim, so this line matters.

The generated `vite.config.ts` enables the Hem plugin:

```ts
import { defineConfig } from "vite";
import { hem } from "@hem-lang/vite-plugin";

export default defineConfig({
  plugins: [hem()],
});
```

## Step 2: write a Hem module

Open the generated `src/payment.hem`:

```hem title="src/payment.hem"
export type PaymentState =
  | Pending
  | Paid({ method: string })
  | Refunded({ reason: string })

export let describe = (state: PaymentState) =>
  match state {
  | Pending => "Awaiting payment"
  | Paid({ method }) => `Paid via ${method}`
  | Refunded({ reason }) => `Refunded: ${reason}`
  }
```

We declared a variant with three members, then a function that matches on it. There is no `switch` and no `default` case because every member is listed.

## Step 3: check it

```sh
npm run check
```

The command prints nothing and exits successfully. `hem check` reports problems and produces no output when there are none.

## Step 4: read the TypeScript it emits

This step shows the TypeScript that Hem generates.

```sh
npm run build
```

The output names the file it wrote:

```
src/payment.ts
```

Open `src/payment.ts`. This excerpt omits its final inline source-map annotation:

```ts emitted title="src/payment.ts — generated"
// Generated from payment.hem by hem — do not edit. hem:sha256=6b344cc65f6297eb
export type PaymentState =
  | { readonly _tag: "Pending" }
  | { readonly _tag: "Paid"; readonly method: string }
  | { readonly _tag: "Refunded"; readonly reason: string };

export const Pending: PaymentState = { _tag: "Pending" };
export const Paid = (fields: { readonly method: string }): PaymentState => ({ _tag: "Paid", ...fields });
export const Refunded = (fields: { readonly reason: string }): PaymentState => ({ _tag: "Refunded", ...fields });
export const describe = (state: PaymentState) => {
  switch (state._tag) {
case "Pending": {
  return "Awaiting payment";
}
case "Paid": {
  const { method } = state;
  return `Paid via ${method}`;
}
case "Refunded": {
  const { reason } = state;
  return `Refunded: ${reason}`;
}
  }
};
```

The variant became a discriminated union with a `_tag` field, plus one constructor per member. This is what any TypeScript file importing this module will see.

The `match` became a plain `switch` whose cases return their values directly. There is no helper import at the top of the file and no runtime library anywhere: this file has no dependencies at all.

The file sits next to the source rather than in a build directory. That is deliberate, so every other tool in a project keeps seeing ordinary TypeScript.

The banner on line one names the source and hashes the generated body. Edit the `.hem` file beside it: build, watch, and Vite refuse to overwrite an edited sibling (HEM3008), and `hem check` reports it too. If you edited `payment.ts`, move the change into `payment.hem`, then delete `payment.ts` (or restore it with git) and rebuild.

## Step 5: break it on purpose

Delete the `Refunded` arm from `src/payment.hem`, leaving the declaration alone:

<!-- Omitting one declared variant is the exhaustiveness error this step demonstrates. -->
```hem
export let describe = (state: PaymentState) =>
  match state {
  | Pending => "Awaiting payment"
  | Paid({ method }) => `Paid via ${method}`
  }
```

Check again:

```sh
npm run check
```

The compiler reports an error:

```
error[HEM2005] src/payment.hem:7:3
  |
7 |   match state {
  |   ^^^^^^^^^^^^^ This match is missing `Refunded`. Add an explicit arm for each missing case.
```

The error points to line 7 of the source file rather than the generated TypeScript, names the missing member instead of reporting a type mismatch, and asks for an explicit arm to handle it.

A match over a Hem variant is exhaustive: adding another variant member causes incomplete matches to produce a compile error rather than falling through at runtime.

## Step 6: fix it

Put the arm back:

```hem
export let describe = (state: PaymentState) =>
  match state {
  | Pending => "Awaiting payment"
  | Paid({ method }) => `Paid via ${method}`
  | Refunded({ reason }) => `Refunded: ${reason}`
  }
```

```sh
npm run check
```

The command exits with no output.

## Step 7: write a test

Tests are ordinary TypeScript. The starter creates `src/payment.test.ts`:

```ts
import { expect, test } from "vitest";
import { Paid, Refunded, describe } from "./payment.hem";

test("describes each payment state", () => {
  expect(describe(Paid({ method: "iDEAL" }))).toBe("Paid via iDEAL");
  expect(describe(Refunded({ reason: "duplicate" }))).toBe("Refunded: duplicate");
});
```

Notice the import specifier: we import `./payment.hem`, and Hem resolves it to the emitted sibling. We call the constructors the variant generated for us.

Run it:

```sh
npm test
```

```
 Test Files  1 passed (1)
  Tests  1 passed (1)
```

Vitest loaded `vite.config.ts`, where `hem()` compiled the Hem modules for the tests. Keep using the project's Vitest command when a Vite or Vitest config exists; `hem test` is for projects without one.

## What we built

We wrote a Hem module, read the TypeScript it compiles to, saw the compiler catch a missing case at the exact line we wrote, and got a test passing against the emitted output.

This demonstrates Hem's design: a smaller feature set than TypeScript, standard emitted TypeScript, and diagnostics that point directly to the source file.

## Where to go next

- [The tour](/) walks the rest of the language with the TypeScript each feature emits.
- [Add Hem to a Vite project](/guides/set-up-a-project) covers doing this in a real application, including JSX.
- The `examples/recurring-invoice-preview` project in the repository is a complete React application with Hem owning the form state, validation, and date arithmetic.

Source: https://hem.run/tutorial/first-hem-app/index.mdx
