Skip to content

Your first Hem module

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.

Updated View as Markdown

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

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

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:

{
  "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:

{
  "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:

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:

src/payment.hemhem
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

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.

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:

src/payment.ts — generatedts
// 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:

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

Check again:

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:

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

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:

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

Type to search…

↑↓ navigate↵ selectEsc close