Skip to content

Same types. Sharper language.

Hem is a small language whose types are TypeScript's types, checked by tsc against your own tsconfig. Every .hem file compiles to a .ts sibling you can read, review, and check in. Hem adds no runtime of its own, so deleting the compiler leaves you with an ordinary TypeScript codebase.

Hem is not on npm yet. Adding Hem to a project shows how to use it from the repository today.

billing.hem
export type PaymentState =  | Pending  | Paid({ at: Date, method: string })  | Refunded({ reason: string })export let label = (s: PaymentState) =>  match s {  | Pending => "Awaiting payment"  | Paid({ method }) => `Paid via ${method}`  | Refunded({ reason }) => `Refunded: ${reason}`  }
billing.tsemitted10 lines in, 27 out
export type PaymentState =  | { readonly _tag: "Pending" }  | { readonly _tag: "Paid"; readonly at: Date; readonly method: string }  | { readonly _tag: "Refunded"; readonly reason: string };export const Pending: PaymentState = { _tag: "Pending" };export const Paid = (fields: {  readonly at: Date;  readonly method: string;}): PaymentState => ({ _tag: "Paid", ...fields });export const Refunded = (fields: {  readonly reason: string;}): PaymentState => ({ _tag: "Refunded", ...fields });export const label = (s: PaymentState) => {  switch (s._tag) {    case "Pending": {      return "Awaiting payment";    }    case "Paid": {      const { method } = s;      return `Paid via ${method}`;    }    case "Refunded": {      const { reason } = s;      return `Refunded: ${reason}`;    }  }};
On disk

What you add to a project.

billing.hem compiles to a billing.ts sibling whose first line names its source and hashes its body. Other Hem files import it as ./billing.hem, which compiles to ./billing.ts. Plain TypeScript imports ./billing like any other module, so bundlers, test runners, and editors resolve it with no Hem plugin.

While you write in Hem, the build owns the sibling: it refuses to overwrite an edited sibling, so edit the .hem source instead. Commit siblings so reviewers and downstream tools see real TypeScript, or ignore them and let the build produce them. Each build also writes a manifest that formatters, linters, coverage, and watchers can use to skip generated files.

src/
├─ billing.hemyou write this
├─ billing.tsthe compiler writes this; don't edit it
└─ checkout.tsplain TypeScript, imports "./billing"
// Generated from billing.hem by hem — do not edit. hem:sha256=eb6773928f2e1aec
Build time
Hem parses your source, applies its own rules, and prints TypeScript. The type checking is tsc's, against your real tsconfig, with your resolution, libraries, paths, and JSX.
Edit time
A language server over LSP. Diagnostics land on the line you wrote rather than the generated one, and a file formatted in your editor is byte-identical to one formatted in CI.
Run time
Core syntax adds no runtime imports. Explicit libraries remain application dependencies: @hem-lang/std supplies boxed carriers, and the opt-in Effect extension imports the configured Effect module. Retaining emitted TypeScript lets you leave Hem while keeping any libraries the application uses.
Read the chapter: Setup
Expressions

Everything is an expression.

An if needs no parentheses and produces a value. Shadowing binds a name again; each step can change its type, from ?string to string to number here. The compiler numbers the names in the TypeScript it emits.
  • Outside classes, arrows are the only way to write a function, so nothing hoists and this never rebinds
  • A block after => yields its last expression
  • An if with no else has type ?T
  • x |> f is f(x) and x |> f(a) is f(x, a), so a pipeline emits ordinary calls and imports nothing
report.hem
let size = if n > 1000 { "large" } else { "small" }let readPort = (env: Env): number => {  let port = env.PORT  let Some(port) = port else return 8080  let port = Number.parseInt(port, 10)  if Number.isNaN(port) { 8080 } else { port }}
report.tsemitted
const size = n > 1000 ? "large" : "small";const readPort = (env: Env): number => {  const port = env.PORT;  const _hemValue = port;  if (_hemValue === null || _hemValue === undefined) {    return 8080;  }  const port$1 = _hemValue;  const port$2 = Number.parseInt(port$1, 10);  return Number.isNaN(port$2) ? 8080 : port$2;};
slug.hem
let slug = title |> normalize |> truncate(80) |> encodeURIComponent
slug.tsemitted
const slug = encodeURIComponent(truncate(normalize(title), 80));
Read the chapter: Everything is an expression
Absence

One type for absence.

Null and undefined value literals are replaced by contextual None. Exact types such as T | null preserve TypeScript contracts; ?T means T | null | undefined, with no wrapper object at runtime. Some and None patterns check absence, and DOM APIs retain their exact types: getElementById returns Element | null.
  • xs[i] is ?T, so you can't index an array without handling the missing case
  • match, ?., ??, let-else and != None each turn ?T back into T
profile.hem
let Some(user) = findUser(id) else return notFound()let name = user.profile?.name ?? "anonymous"
profile.tsemitted
const _hemValue = findUser(id);if (_hemValue === null || _hemValue === undefined) {  return notFound();}const user = _hemValue;const name = user.profile?.name ?? "anonymous";
Read the chapter: Nothing is null: ?T
Patterns

Exhaustive pattern matching.

match [state, event] builds no tuple at runtime. Each column is tested on its own, and exhaustiveness is checked across all of them. Delete the last arm and the error lists the combinations you skipped.
  • Guards, or-patterns, array patterns, as aliases
  • Discriminated unions imported from TypeScript match with the same literal and record patterns
  • When the type is closed, a missing arm is a compile error that names the case
machine.hem
let step = (state: State, event: Event): State =>  match [state, event] {  | [Idle, Start] => Loading  | [Loading, Finish] => Ready  | [Ready | Done, Reset] => Idle  | [_, _] => state  }
machine.tsemitted
const step = (state: State, event: Event): State => {  if (state._tag === "Idle" && event._tag === "Start") {    return Loading;  } else if (state._tag === "Loading" && event._tag === "Finish") {    return Ready;  } else if ((state._tag === "Ready" || state._tag === "Done") && event._tag === "Reset") {    return Idle;  } else {    return state;  }};
Read the chapter: match
Safety

Common JavaScript mistakes are not valid Hem.

Conditions and the logical operators !, && and || take booleans, never truthy or falsy values. any from imported code becomes unknown.

A call that returns a Promise, an Effect, an Option, or a Result must be used or explicitly discarded, and a statement that computes a value only to drop it is refused. Each rule turns a silent failure into a compile error that shows the fix.

error[HEM2002] dashboard.hem:8:4
|
8 | if items.length {
| ^^^^^^^^^^^^This condition has type `number`, not `boolean`. Compare it explicitly before branching.
if items.length > 0 { flush(items) }

Left out of Hem. What's gone shows what to write instead of each one.

  • var
  • function
  • switch
  • == (coercing)
  • truthiness
  • null
  • undefined
  • any
  • enum
  • interface
  • namespace
  • this (outside classes)
  • for..in
  • for (;;)
  • do..while
  • ++ --
  • labels
  • the comma operator
  • declaration merging
Read the chapter: Equality and boolean discipline
Failures

Tagged error handling.

error declares a tagged Error subclass, raise throws one, and a postfix catch handles errors by tag. Errors a handler doesn't name are rethrown unchanged, so handlers compose.
  • catch attaches to any expression, await included
  • use lowers to the platform's using / await using
save.hem
error QuotaExceeded({ orgId: string })let save = async (doc: Doc) => {  use tx = db.begin()  await push(tx, doc) catch {  | QuotaExceeded({ orgId }) => cached(doc, orgId)  }}
save.tsemitted7 lines in, 24 out
class QuotaExceeded extends Error {  readonly _tag = "QuotaExceeded";  readonly orgId: string;  constructor(fields: { readonly orgId: string }) {    super("QuotaExceeded");    this.name = "QuotaExceeded";    this.orgId = fields.orgId;  }}const save = async (doc: Doc) => {  using tx = db.begin();  let _hemValue;  try {    _hemValue = await push(tx, doc);  } catch (error) {    if (error instanceof QuotaExceeded) {      const { orgId } = error;      _hemValue = cached(doc, orgId);    } else {      throw error;    }  }  return _hemValue;};
Read the chapter: Failures and cleanup
Concurrency

Parallel asynchronous execution.

Join bindings with and and they run concurrently. A binding can't refer to another in the same group, so the order they finish in never matters.
  • Delete the ands and it is sequential again
  • The same shape works over Effect, where a failure interrupts its siblings
dashboard.hem
let dashboard = async (id: string) => {  let user = await fetchUser(id)  and orgs = await fetchOrgs(id)  render(user, orgs)}
dashboard.tsemitted
const dashboard = async (id: string) => {  const [user, orgs] = await Promise.all([fetchUser(id), fetchOrgs(id)]);  return render(user, orgs);};
Read the chapter: Concurrency: and groups
Optional

Effect, when you want it.

Nothing above needs Effect. If your application uses it, turn on the extension and the words you already know change where failures and cleanup travel, not how you write them.raise fails the Effect with a typed error, catch handles its error channel, and use releases on interruption too. The emitted code is ordinary Effect TypeScript.
  • Users! gets a service and findById(id)! runs an Effect, like yield* inside Effect.gen
  • The compiler rejects Effect mistakes TypeScript accepts, such as running an Effect in the wrong place or letting a scope leak
users.hem
let getUser = (id: string) => effect {  let users = Users!  let row = users.findById(id)!  let Some(user) = row else raise UserNotFound({ id })  user}
users.tsemitted
const getUser = (id: string) => Effect.gen(function* () {  const users = yield* Users;  const row = yield* users.findById(id);  const _hemValue = row;  if (_hemValue === null || _hemValue === undefined) {    return yield* Effect.fail(new UserNotFound({ id }));  }  const user = _hemValue;  return user;});
Read the chapter: The Effect extension
Toolchain

The tools ship with the language.

The CLI, the Vite plugin, and the editor all run the same compiler. They differ in when they run, not in what Hem means or the TypeScript they produce.
hem check / build
Check reports diagnostics without writing anything. Build refreshes the generated siblings, removes stale ones, and refuses to overwrite a file you wrote by hand or a generated file someone edited. Build also has a watch mode.
hem fmt
One formatter serves your editor and CI, so both produce byte-identical files.
hem test
Emits siblings, then runs the project's own Vitest with the Vite plugin, so React suites need no separate Hem harness.
hem migrate
Converts TypeScript to Hem only when it can prove the result behaves the same. If any construct in a file can't be converted safely, it leaves the whole file alone.
Vite / VS Code
The Vite plugin refreshes changed siblings in development and builds. The VS Code extension bundles the language server for diagnostics, navigation, formatting, and completions on your Hem source.
Read the guide: Check and build from the CLI
The tour

Read it in an afternoon.

The tour assumes you know TypeScript. Hem's types are TypeScript's types, so that knowledge carries over; what changes is the expression language. Every chapter shows the TypeScript its examples emit. Part I covers the language, Part II the optional Effect extension, and Language limits lists what Hem doesn't support and what to write instead.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close