Hedronite · Frontend Dev · TypeScript · 2026-10-25

Gleam business rules inside a Vite React app vite-gleam, generated types, and a typed boundary

Move pricing rules into a pure Gleam module, compile it to JavaScript inside Vite, and let TypeScript check every call through declarations Gleam writes.

Lesson Class: Dev (TypeScript front end · Gleam on the JavaScript target)
Tested with: gleam 1.19.2 · vite 8.3.5 · vite-gleam 1.7.1 · react 19.3.1 · typescript 7.0.2 · bun 1.4.4 (install only) · macOS + Linux
Lab: tally · gleam test (6 tests) · tsc --noEmit · vite build · ssrLoadModule smoke run · headless Chrome DOM check
Source: Gleam tour, gleam.toml reference and Externals guide · vite-gleam source · Vite docs (Plugin API, migration, SSR) · TSConfig reference · Programming TypeScript ch. 6 and 11 · JavaScript: The Definitive Guide §17.5
Rules Without I/O
Pricing lives in one Gleam module of types and pure functions, tested with gleeunit and no browser.
Errors in the Type
Failures come back as Result values, and a new error variant stops the build until every case handles it.
One Adapter File
checkout.ts is the only file that knows how Gleam values look in JavaScript; components get a plain discriminated union.
Put the rule where the compiler can see every case.

Versions used: gleam 1.19.2 with gleam_stdlib 1.0.5 and gleeunit 1.11.0; vite 8.3.5, vite-gleam 1.7.1, @vitejs/plugin-react 6.1.2, react and react-dom 19.3.1, @types/react and @types/react-dom 19.3.0, typescript 7.0.2 (the same tsconfig also passes on 6.0.3). Every command below gave the same result on macOS (Apple silicon, Node 26.12.1) and Linux x86_64 (Node 22.23.4). Bun 1.4.4 installs the packages and runs nothing else. vite-gleam 1.7.1 was released for Vite 7; the compatibility note in Concept B shows what that means in practice.

The problem

A checkout page in a React app collects pricing rules the way a drawer collects cables. The quantity limit lives in an onChange handler, the discount-code check sits in a hook, and the cents-to-dollars formatting is copied into two components. When the business changes a code from ten percent off to fifteen, someone searches the JSX (the HTML-like syntax React components are written in) for 0.1, finds three candidates, and hopes they changed every one.

TypeScript helps less than it should. Rules report problems by throwing, and a throw never appears in a function's type. A caller that forgets the empty-cart case therefore compiles cleanly and fails in production. The rules are also tangled up with state, events and markup, so a test for "ten percent off $33.99" has to mount a page, type into inputs, and read text back out of the Document Object Model (DOM), the tree of page elements the browser keeps.

That is three separate pains: the rules are scattered, error cases are invisible to the type checker, and the only way to test them is through the user interface. The fix below moves the rules into a small pricing module in a project called tally, written in Gleam, compiled to JavaScript, and imported by a Vite + React + TypeScript app. Vite is a build tool and development server for web front ends, React is a library that builds user interfaces from components, and Bun, a JavaScript toolchain, is used here only to install packages.

The idea in plain words

Put every pricing rule in one Gleam module that does no input or output (I/O): values go in, values come out. Gleam's compiler will not build code that leaves a case of a custom type or a Result unhandled, so adding a new error forces every caller to decide what to do with it. The vite-gleam plugin runs the Gleam compiler for Vite and lets TypeScript files import the module. TypeScript checks each call against declaration files that Gleam writes. Gleam values do not look like ordinary TypeScript objects, so one small adapter file, checkout.ts, turns them into plain objects that components can render.

Concept A: the Gleam domain module and the JavaScript target

Gleam is a statically typed functional language whose compiler can emit either Erlang or JavaScript. Choosing the JavaScript target is what makes the module usable from a web app, because the output is plain ES modules, the standard JavaScript module format built on import and export. The project is configured in gleam.toml:

name = "tally"
version = "1.0.0"
target = "javascript"

[javascript]
typescript_declarations = true

[dependencies]
gleam_stdlib = ">= 1.0.0 and < 2.0.0"

[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

target sets the default compilation target. Without it the default is "erlang", and the --target flag can override it. typescript_declarations defaults to false; turning it on makes the compiler write TypeScript declaration files next to the JavaScript, and the rest of this article depends on them. Gleam 1.19.2 writes them as .d.mts files, the declaration form for ES module JavaScript files ending in .mjs. The name matters later, because the test runner and the plugin both derive file paths from it.

The rules themselves are one file:

//// Checkout pricing rules: types and pure functions, no I/O.

import gleam/bool
import gleam/int
import gleam/list
import gleam/string

pub type Line {
  Line(sku: String, unit_cents: Int, quantity: Int)
}

pub type Discount {
  NoDiscount
  Percent(Int)
  AmountOff(cents: Int)
}

pub type PricingError {
  EmptyCart
  BadQuantity(sku: String, quantity: Int)
  UnknownCode(code: String)
}

pub type Quote {
  Quote(subtotal_cents: Int, discount_cents: Int, total_cents: Int)
}

/// A cart the caller can hold but not take apart.
pub opaque type Cart {
  Cart(lines: List(Line))
}

pub fn empty_cart() -> Cart {
  Cart([])
}

pub fn add_line(
  cart: Cart,
  sku: String,
  unit_cents: Int,
  quantity: Int,
) -> Result(Cart, PricingError) {
  case quantity {
    q if q < 1 || q > 99 -> Error(BadQuantity(sku, q))
    q -> Ok(Cart([Line(sku, unit_cents, q), ..cart.lines]))
  }
}

pub fn parse_code(code: String) -> Result(Discount, PricingError) {
  case string.uppercase(string.trim(code)) {
    "" -> Ok(NoDiscount)
    "SAVE10" -> Ok(Percent(10))
    "FIVEOFF" -> Ok(AmountOff(500))
    other -> Error(UnknownCode(other))
  }
}

pub fn quote(cart: Cart, discount: Discount) -> Result(Quote, PricingError) {
  use <- bool.guard(cart.lines == [], Error(EmptyCart))
  let subtotal =
    list.fold(cart.lines, 0, fn(acc, line) {
      acc + line.unit_cents * line.quantity
    })
  let off = case discount {
    NoDiscount -> 0
    Percent(p) -> subtotal * p / 100
    AmountOff(cents) -> int.min(cents, subtotal)
  }
  Ok(Quote(subtotal, off, subtotal - off))
}

pub fn describe_error(error: PricingError) -> String {
  case error {
    EmptyCart -> "Add at least one item."
    BadQuantity(sku, q) ->
      sku <> ": quantity " <> int.to_string(q) <> " is outside 1 to 99."
    UnknownCode(code) -> "Code " <> code <> " is not valid."
  }
}

pub fn format_cents(cents: Int) -> String {
  let dollars = int.to_string(cents / 100)
  let rest = int.to_string(cents % 100) |> string.pad_start(2, "0")
  "$" <> dollars <> "." <> rest
}

The pub type declarations are custom types. Each lists its variants, and a case expression must cover all of them. That is why describe_error has exactly three arms. A Line carries a sku (stock-keeping unit, the product identifier), a unit price in cents and a quantity. If you add a fourth PricingError variant, this file stops compiling until the message exists. Adding ExpiredCode(code: String) to the type and rebuilding gives:

$ gleam build --target javascript
  Compiling tally
error: Inexhaustive patterns
   ┌─ /workspace/asr1025/lab-exh/src/pricing.gleam:74:3
   │  
74 │ ╭   case error {
75 │ │     EmptyCart -> "Add at least one item."
76 │ │     BadQuantity(sku, q) ->
77 │ │       sku <> ": quantity " <> int.to_string(q) <> " is outside 1 to 99."
78 │ │     UnknownCode(code) -> "Code " <> code <> " is not valid."
79 │ │   }
   │ ╰───^

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

    ExpiredCode(code:)

The compiler names the missing variant and the exact case that lacks it, so a new rule cannot ship half-handled. Gleam has no exceptions. A function that can fail returns Result(value, error), holding either Ok(value) or Error(error), so the signature of quote tells every caller which failures to expect, and the compiler checks that they are handled.

Cart is an opaque type, meaning its constructor is private to this module, and no outside code can build or take apart a Cart. Callers have to go through add_line, a smart constructor: a function that builds a value but is stricter than the raw constructor. Here it rejects quantities below 1 or above 99 using a guard (q if q < 1 || q > 99), the if clause on a case pattern. Guards cannot contain function calls, which is why the comparison is written with operators.

In quote, Gleam has no early return, so use <- bool.guard(cart.lines == [], Error(EmptyCart)) does the job. If the condition is true, the guard returns the error. Otherwise it calls a callback, and everything below the use line becomes that callback's body, without extra indentation. Money is stored as whole cents in Int, and subtotal * p / 100 is integer division.

Build it:

$ gleam build --target javascript
Downloading packages
 Downloaded 2 packages in 0.00s
  Compiling gleam_stdlib
  Compiling gleeunit
  Compiling tally
   Compiled in 0.08s
exit=0

The first build downloads the two dependencies from Hex, the package registry for Erlang-ecosystem languages, and compiles them before the project itself. The result lands in build/dev/javascript/tally/, one directory per package, with the standard library beside it in build/dev/javascript/gleam_stdlib/. Later builds skip the download and recompile only what changed. The generated pricing.mjs is ordinary ES module JavaScript. Its head and the quote function look like this:

/// <reference types="./pricing.d.mts" />
import * as $bool from "../gleam_stdlib/gleam/bool.mjs";
import * as $int from "../gleam_stdlib/gleam/int.mjs";
import * as $list from "../gleam_stdlib/gleam/list.mjs";
import * as $string from "../gleam_stdlib/gleam/string.mjs";
import {
  Ok,
  Error,
  Empty as $Empty,
  List$Empty$const as $List$Empty$const,
  prepend as listPrepend,
  CustomType as $CustomType,
} from "./gleam.mjs";
// ...
export function quote(cart, discount) {
  return $bool.guard(
    cart.lines instanceof $Empty,
    new Error(PricingError$EmptyCart$const),
    () => {
      let subtotal = $list.fold(
        cart.lines,
        0,
        (acc, line) => { return acc + line.unit_cents * line.quantity; },
      );
      let _block;
      if (discount instanceof NoDiscount) {
        _block = 0;
      } else if (discount instanceof Percent) {
        let p = discount[0];
        _block = globalThis.Math.trunc((subtotal * p) / 100);
      } else {
        let cents = discount.cents;
        _block = $int.min(cents, subtotal);
      }
      let off = _block;
      return new Ok(new Quote(subtotal, off, subtotal - off));
    },
  );
}

The use became the callback passed to $bool.guard, and the empty-list test became an instanceof check. The line to notice is Math.trunc((subtotal * p) / 100). On JavaScript runtimes, Gleam ints are represented as JavaScript's 64-bit floating point numbers, so the compiler adds the truncation to keep Gleam's whole-number division. Keep in mind that the same representation means the Erlang target's unlimited integer size does not carry over; cents in a checkout stay far below the range where floating point loses whole numbers. For the sample cart, 3399 × 10 / 100 is 339.9, and the rule yields 339 cents. The import ... from "./gleam.mjs" line also matters: that file is the Gleam prelude, which Concept C uses again.

The rules are tested with gleeunit, a test runner package for Gleam, and no React in sight:

import gleam/list
import gleeunit
import pricing.{AmountOff, BadQuantity, EmptyCart, Percent, Quote, UnknownCode}

pub fn main() -> Nil {
  gleeunit.main()
}

fn cart_of(items: List(#(String, Int, Int))) -> pricing.Cart {
  let assert Ok(cart) =
    list.try_fold(items, pricing.empty_cart(), fn(cart, item) {
      let #(sku, unit_cents, quantity) = item
      pricing.add_line(cart, sku, unit_cents, quantity)
    })
  cart
}

pub fn subtotal_and_percent_test() {
  let cart = cart_of([#("mug", 1250, 2), #("tea", 899, 1)])
  assert pricing.quote(cart, Percent(10)) == Ok(Quote(3399, 339, 3060))
}

pub fn amount_off_never_goes_negative_test() {
  let cart = cart_of([#("sticker", 300, 1)])
  assert pricing.quote(cart, AmountOff(500)) == Ok(Quote(300, 300, 0))
}

pub fn empty_cart_is_an_error_test() {
  assert pricing.quote(pricing.empty_cart(), Percent(10)) == Error(EmptyCart)
}

pub fn quantity_bounds_test() {
  assert pricing.add_line(pricing.empty_cart(), "mug", 1250, 0)
    == Error(BadQuantity("mug", 0))
  assert pricing.add_line(pricing.empty_cart(), "mug", 1250, 100)
    == Error(BadQuantity("mug", 100))
}

pub fn codes_are_trimmed_and_case_folded_test() {
  assert pricing.parse_code("  save10 ") == Ok(Percent(10))
  assert pricing.parse_code("BOGUS") == Error(UnknownCode("BOGUS"))
}

pub fn format_cents_pads_test() {
  assert pricing.format_cents(3060) == "$30.60"
  assert pricing.format_cents(5) == "$0.05"
}
$ gleam test
   Compiled in 0.01s
    Running tally_test.main
......
6 passed, no failures
exit=0

gleeunit runs every public function whose name ends in _test. The assert keyword checks that a boolean expression is True and is meant for test code. let assert Ok(cart) = ... in the helper crashes the test if the setup fails, which is acceptable in a fixture. Each test compares whole Result values, so Ok(Quote(3399, 339, 3060)) pins the subtotal, the discount and the total at once. Nothing here starts Vite or touches the DOM, and the six dots appear in a fraction of a second.

Common mistake: naming the test file after the module under test.

   Compiled in 0.01s
error: Module does not exist

Module `tally_test` was not found.

Hint: Try creating the file `test/tally_test.gleam`.

Concept B: vite-gleam, the plugin that compiles Gleam inside Vite

A Vite plugin is an object with hooks that Vite calls while it resolves, loads and transforms modules. Vite 8 is built on Rolldown, a module build tool with a Rollup-style plugin system, and Vite's documentation says its plugins extend Rolldown's plugin interface, so one plugin works for both vite build and the dev server. In dev, Vite calls options and buildStart once when the server starts, and calls resolveId, load and transform for each module request. The migration guide states the change plainly: "Vite 8 uses Rolldown and Oxc based tools instead of esbuild and Rollup." That matters for a plugin written against Vite 7, as the compatibility note below shows.

You need a plugin because importing a .gleam file is not standard JavaScript. A build tool that joins modules into files a browser can load must be taught what such a file means, and your code then depends on that tool. Without the plugin, Vite would not know what to do with import { quote } from "./pricing.gleam".

The plugin is about 130 lines of TypeScript (src/index.ts in version 1.7.1), and reading it explains every behavior below. In buildStart it reads ./gleam.toml, throwing "gleam.toml not found" if the file is missing, and then runs gleam build --target=javascript through Node's execSync. In transform, any module id ending in .gleam is replaced by the compiled file at build/dev/javascript/<name>/<path>.mjs, where <name> comes from gleam.toml. In resolveId, an import whose source ends in gleam.mjs, or one made from inside a Gleam module, is resolved relative to build/dev/javascript/<project>. When a .gleam file changes, handleHotUpdate runs the build again; that hot-reload path was not exercised for this article. The configuration is two plugin calls:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import gleam from "vite-gleam";

export default defineConfig({
  plugins: [gleam(), react()],
});

gleam() takes no options here, and it comes before react() so that .gleam imports are resolved by the plugin that understands them. @vitejs/plugin-react handles the JSX in .tsx files and React's fast refresh, which updates edited components in the browser without a full page reload. The package.json pins exact versions, with no caret ranges, so every install resolves the same packages. Bun only reads this file to install; the scripts call gleam, tsc and vite directly.

{
  "name": "tally",
  "private": true,
  "type": "module",
  "scripts": {
    "gleam": "gleam build --target javascript",
    "typecheck": "gleam build --target javascript && tsc --noEmit -p .",
    "build": "vite build",
    "dev": "vite"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "typescript": "7.0.2",
    "vite": "8.3.5",
    "vite-gleam": "1.7.1"
  }
}

The typecheck script builds Gleam first, because tsc reads the declaration files that the Gleam build writes; run tsc on a fresh clone without that step and every Gleam import fails to resolve. The dev server for this run listened on port 5179. In the dev server, curl shows what happens to an import. The first request asks for the Gleam file directly and gets the raw source back with an empty Content-Type:

$ curl -s -D- http://localhost:5179/src/pricing.gleam | head -20
HTTP/1.1 200 OK
Vary: Origin
Content-Length: 2036
Content-Type: 
Last-Modified: Sun, 25 Oct 2026 20:32:25 GMT
ETag: W/"2036-1792960345286"
Cache-Control: no-cache
Date: Sun, 25 Oct 2026 20:37:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5

//// Checkout pricing rules: types and pure functions, no I/O.

import gleam/bool
import gleam/int
import gleam/list
import gleam/string

pub type Line {
  Line(sku: String, unit_cents: Int, quantity: Int)

The page never makes this request. Browsers refuse to run a module script whose Content-Type is not a JavaScript type, and the Gleam text would not parse as JavaScript in any case. What the page loads depends on import rewriting. When Vite serves checkout.ts, it rewrites the imports, and the rewritten import carries a ?import suffix:

$ curl -s http://localhost:5179/src/checkout.ts | grep pricing.gleam
2:import { Result$isOk, Result$Ok$0, Result$Error$0 } from "/build/dev/javascript/tally/gleam.mjs";
3:import { add_line, describe_error, empty_cart, format_cents, parse_code, quote, Quote$Quote$discount_cents, Quote$Quote$subtotal_cents, Quote$Quote$total_cents } from "/src/pricing.gleam?import";

$ curl -s -D- "http://localhost:5179/src/pricing.gleam?import" | head -14
HTTP/1.1 200 OK
Vary: Origin
Content-Type: text/javascript
Cache-Control: no-cache
Etag: W/"193a-Pf8aX/7l0EPP+FGri3YL7hBCum8"
Date: Sun, 25 Oct 2026 20:37:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 10773

/// <reference types="./pricing.d.mts" />
import * as $bool from "/build/dev/javascript/gleam_stdlib/gleam/bool.mjs";
import * as $int from "/build/dev/javascript/gleam_stdlib/gleam/int.mjs";
import * as $list from "/build/dev/javascript/gleam_stdlib/gleam/list.mjs";

The ?import request returns text/javascript, which is the compiled pricing.mjs: transform swapped the Gleam source for the build output. The Content-Length grew from 2,036 bytes of Gleam to 10,773 bytes of JavaScript. The ./gleam.mjs import in checkout.ts became /build/dev/javascript/tally/gleam.mjs, which is resolveId at work, and the standard library imports inside the compiled module point at /build/dev/javascript/gleam_stdlib/.... The production build runs the same hooks:

$ npx vite build
vite v8.3.5 building client environment for production...
$ gleam build --target=javascript
   Compiled in 0.01s

transforming...
✓ 34 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                  0.24 kB │ gzip:  0.19 kB
dist/assets/index-qSYTCzCX.js  224.67 kB │ gzip: 70.45 kB

✓ built in 183ms
exit=0

The $ gleam build --target=javascript line is the plugin's buildStart logging the command it ran. The single JavaScript file Vite wrote to dist/assets/ contains the compiled Gleam code, the parts of the standard library it uses, React and the app. The 34 modules counted in the log include the Gleam prelude and standard-library files, which shows that resolveId found them during the production build as well. The file name carries a content hash, and it came out identical on both test machines.

Compatibility note. vite-gleam 1.7.1 was published on 2025-11-22 and is the latest release, but it was written for Vite 7. Its README says: "Given the version number X.Y.Z, X refers to a breaking change, Y refers to the Vite version, and Z refers to a minor change," so 1.7.x means Vite 7. Its package.json lists "vite": "^7.2.4" under dependencies, not peerDependencies. Installing it next to Vite 8 therefore pulls a second, nested copy of Vite, as bun pm ls --all shows (filtered here):

├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
    └── [email protected]

The plugin's source imports only the Plugin type from vite, so the nested copy is never executed. It still costs install time and disk space, and it would cause real trouble if a future plugin release started calling Vite 7 functions at runtime. In the test runs the plugin ran under Vite 8.3.5, which uses Rolldown, for both vite build and the dev server. That is tested behavior for these exact versions and not a guarantee for later ones.

**Common mistake: running Vite where gleam is not on the PATH.**

vite v8.3.5 building client environment for production...
$ gleam build --target=javascript
/bin/sh: 1: gleam: not found
✓ 0 modules transformed.
✗ Build failed in 27ms
error during build:
Build failed with 1 error:

[plugin gleam]
Error: Command failed: gleam build --target=javascript
/bin/sh: 1: gleam: not found
    at genericNodeError (node:internal/errors:984:15)
    at wrappedFn (node:internal/errors:538:14)
    ...

Concept C: the typed boundary between Gleam and TypeScript

A boundary is the place where values written in one language are used by code in another. Here it has two parts: what the generated JavaScript looks like, and how TypeScript learns about it. Both matter because Gleam values are not plain objects, and a mistake at the boundary would otherwise surface only at runtime.

Since Gleam 1.13, compiled code exposes an application programming interface (API) for its data types. For each variant there is a TypeName$VariantName constructor function and a TypeName$isVariantName test. Fields get accessor functions: positional ones are numbered (Quote$Quote$0) and labelled ones use the label (Quote$Quote$subtotal_cents). Here is the generated pricing.d.mts for Quote and the Cart functions:

export class Quote extends _.CustomType {
  /** @deprecated */
  constructor(
    subtotal_cents: number,
    discount_cents: number,
    total_cents: number
  );
  /** @deprecated */
  subtotal_cents: number;
  /** @deprecated */
  discount_cents: number;
  /** @deprecated */
  total_cents: number;
}
export function Quote$Quote(
  subtotal_cents: number,
  discount_cents: number,
  total_cents: number,
): Quote$;
export function Quote$isQuote(value: any): value is Quote$;
export function Quote$Quote$0(value: Quote$): number;
export function Quote$Quote$subtotal_cents(value: Quote$): number;
export function Quote$Quote$1(value: Quote$): number;
export function Quote$Quote$discount_cents(value: Quote$): number;
export function Quote$Quote$2(value: Quote$): number;
export function Quote$Quote$total_cents(value: Quote$): number;

export type Quote$ = Quote;

declare class Cart extends _.CustomType {
  /** @deprecated */
  constructor(lines: _.List<Line$>);
  /** @deprecated */
  lines: _.List<Line$>;
}

export type Cart$ = Cart;

export function empty_cart(): Cart$;

export function add_line(
  cart: Cart$,
  sku: string,
  unit_cents: number,
  quantity: number
): _.Result<Cart$, PricingError$>;

export function parse_code(code: string): _.Result<Discount$, PricingError$>;

export function quote(cart: Cart$, discount: Discount$): _.Result<
  Quote$,
  PricingError$
>;

export function describe_error(error: PricingError$): string;

export function format_cents(cents: number): string;

The classes and their fields carry the JSDoc @deprecated tag (JSDoc comments are documentation comments that TypeScript and editors read), so editors show a direct read of .subtotal_cents crossed out (the command-line tsc still accepts it). The supported interface is the set of generated functions, which keeps your code working if the compiler changes how it lays out the classes. Note that Cart is declared but not exported, only the type Cart$ is. That is the opaque type surviving the trip, and a TypeScript file can hold a cart but cannot name its constructor. Lists are the other awkward case: the Gleam documentation says working with Gleam lists in JavaScript is not as nice as in Gleam, which is why the cart's List stays inside Gleam.

Result comes from the Gleam prelude, a module the build tool provides. The documentation says it "can be imported as if it were located at src/gleam.mjs in any Gleam project", and that you should not create a file at that location. Result$isOk tells you whether a result is Ok, and Result$Ok$0 and Result$Error$0 return the inner value. Calling Result$Ok$0 on an Error gives undefined according to its declared type, so always check first.

Gleam generated these declarations from the Gleam signatures, so they cannot drift from the code. Now TypeScript must find declarations for two kinds of import: ./pricing.gleam and ./gleam.mjs. For the first, the option allowArbitraryExtensions tells the compiler that when an import path ends in an unknown extension, it should look for a declaration file named {file basename}.d.{extension}.ts. For ./pricing.gleam that is pricing.d.gleam.ts. For the second, rootDirs declares that several directories act as one virtual root, so relative imports resolve as if the directories were merged. It does not affect emitted JavaScript. The compiler configuration is:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "allowArbitraryExtensions": true,
    "types": [
      "vite/client"
    ],
    "rootDirs": [
      "src",
      "build/dev/javascript/tally"
    ]
  },
  "include": [
    "src"
  ]
}

The other options are the usual ones for a Vite React project. moduleResolution: "bundler" resolves imports the way Vite does, noEmit leaves JavaScript output to Vite so tsc only checks types, isolatedModules rejects code that cannot be compiled one file at a time, types: ["vite/client"] adds Vite's client types, and skipLibCheck skips checking the declaration files of installed packages. include lists only src, yet the generated declarations are still read, because imports pull them in through rootDirs.

Next to it sits the shim, a one-line declaration file for the .gleam extension:

// Types for `import ... from "./pricing.gleam"`.
// With rootDirs, "./pricing.mjs" finds the pricing.d.mts that `gleam build` writes.
export * from "./pricing.mjs";

The chain works like this. TypeScript sees ./pricing.gleam in src/checkout.ts and finds src/pricing.d.gleam.ts. That file re-exports from ./pricing.mjs, which rootDirs resolves in the merged root to build/dev/javascript/tally/pricing.d.mts, the file Gleam wrote. Likewise ./gleam.mjs resolves to Gleam's own gleam.d.mts, which re-exports the prelude types. The build also copies the non-Gleam files it finds in src/ into its build directory. A listing after gleam build shows the React files sitting beside the compiled module:

$ ls build/dev/javascript/tally
App.tsx
_gleam_artefacts
checkout.ts
gleam.d.mts
gleam.mjs
gleam@@private_main_v1.19.2.mjs
main.tsx
pricing.d.gleam.ts
pricing.d.mts
pricing.mjs
tally_test.d.mts
tally_test.mjs

The copies are never imported, so they cost nothing here beyond a few kilobytes of disk. Keep in mind that the copied shim also resolves through rootDirs, so deleting src/pricing.d.gleam.ts without a clean build can leave a stale copy that still satisfies tsc.

All the awkward Gleam details live in one adapter, the only file that knows how Gleam values look in JavaScript:

// The only file that knows how Gleam values look in JavaScript.
import { Result$isOk, Result$Ok$0, Result$Error$0 } from "./gleam.mjs";
import {
  add_line,
  describe_error,
  empty_cart,
  format_cents,
  parse_code,
  quote,
  Quote$Quote$discount_cents,
  Quote$Quote$subtotal_cents,
  Quote$Quote$total_cents,
  type Cart$,
  type PricingError$,
  type Quote$,
} from "./pricing.gleam";

export type Item = { sku: string; unitCents: number; quantity: number };

export type Checkout =
  | { ok: true; subtotal: string; discount: string; total: string }
  | { ok: false; message: string };

function fail(error: PricingError$ | undefined): Checkout {
  return { ok: false, message: error ? describe_error(error) : "Unknown error" };
}

export function checkout(items: Item[], code: string): Checkout {
  let cart: Cart$ = empty_cart();
  for (const item of items) {
    const next = add_line(cart, item.sku, item.unitCents, item.quantity);
    if (!Result$isOk(next)) return fail(Result$Error$0(next));
    cart = Result$Ok$0(next)!;
  }

  const discount = parse_code(code);
  if (!Result$isOk(discount)) return fail(Result$Error$0(discount));

  const result = quote(cart, Result$Ok$0(discount)!);
  if (!Result$isOk(result)) return fail(Result$Error$0(result));

  const q: Quote$ = Result$Ok$0(result)!;
  return {
    ok: true,
    subtotal: format_cents(Quote$Quote$subtotal_cents(q)),
    discount: format_cents(Quote$Quote$discount_cents(q)),
    total: format_cents(Quote$Quote$total_cents(q)),
  };
}

Checkout is a discriminated union: a union whose members share a tag field typed as a literal, here ok: true or ok: false, so that checking the tag narrows the type. Boris Cherny's Programming TypeScript lists what makes a good tag: it sits in the same place in each member, is a literal type, is not generic, and is mutually exclusive, meaning no two members share a tag value. After if (result.ok), TypeScript knows subtotal exists, and in the other branch only message does. Every Gleam Result is opened with Result$isOk before Result$Ok$0 is called, matching the documented requirement. Result$Ok$0 is declared as returning T | undefined, so the ! after each call, TypeScript's non-null assertion, tells the compiler the value is present. That is safe because the preceding Result$isOk check already ran, and fail falls back to "Unknown error" if an error value is missing. Components then see a plain object with strings that are already formatted, and nothing Gleam-shaped leaks past this file.

The typing is real. If line 31 of checkout.ts passes String(item.unitCents) to add_line instead of a number, tsc rejects it:

src/checkout.ts(31,43): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
exit=1

The parameter type came from pricing.d.mts, which came from the Gleam signature unit_cents: Int. Change a Gleam signature, rebuild, and every stale call in TypeScript is reported.

The vite-gleam README suggests an editor plugin instead. That plugin, ts-gleam from the same repository, is a TypeScript language service plugin: code that the editor's TypeScript server loads to change what the editor reports. Its README says it does not build or resolve imports and only makes the editor type-check correctly. The TypeScript project's guide to these plugins says they "aren't loaded during normal commandline typechecking or emitting, (so are not loaded by tsc)", so a type check in CI would see none of its types. That is the reason this setup uses declaration files, which tsc reads everywhere; ts-gleam itself was not tested here. The README's other option, declare module "*.gleam";, is what Cherny calls an ambient module declaration with a wildcard. Written without a body it makes every import compile and types every export as any, which gives up the checking this setup exists for.

Common mistake: no shim file.

src/checkout.ts(16,8): error TS2307: Cannot find module './pricing.gleam' or its corresponding type declarations.
exit=1

Putting it together

The React side contains no pricing logic. App.tsx calls checkout() on every render and displays whichever branch of the union it gets:

import { useState } from "react";
import { checkout, type Item } from "./checkout";

const ITEMS: Item[] = [
  { sku: "mug", unitCents: 1250, quantity: 2 },
  { sku: "tea", unitCents: 899, quantity: 1 },
];

export function App() {
  const [code, setCode] = useState("SAVE10");
  const [mugs, setMugs] = useState(2);
  const result = checkout([{ ...ITEMS[0], quantity: mugs }, ITEMS[1]], code);

  return (
    <main>
      <h1>Tally checkout</h1>
      <label>
        Mugs <input type="number" value={mugs} onChange={(e) => setMugs(Number(e.target.value))} />
      </label>
      <label>
        Code <input value={code} onChange={(e) => setCode(e.target.value)} />
      </label>
      {result.ok ? (
        <p>
          Subtotal {result.subtotal}, discount {result.discount}, total <strong>{result.total}</strong>
        </p>
      ) : (
        <p role="alert">{result.message}</p>
      )}
    </main>
  );
}

The component keeps two pieces of state, the code typed by the user and the number of mugs, and rebuilds the item list from them on each render. Calling checkout() during render is fine here because it is a pure function: the same inputs always give the same Checkout, and nothing outside it changes. The JSX branches on result.ok, and TypeScript allows result.subtotal only in the true branch and result.message only in the other. Typing 0 into the quantity field shows the BadQuantity message in a role="alert" paragraph, so screen readers announce it. The entry file src/main.tsx is the standard React 19 mount:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

The ! after getElementById("root") is the same non-null assertion as before, and it is safe because index.html contains that element. StrictMode runs extra development-only checks, such as rendering components twice to expose impure rendering, which the pure checkout() call passes without trouble:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Tally checkout</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

main.tsx mounts App into the root element of index.html, which is the page Vite serves in development and uses as the entry point for vite build. To exercise the whole path without a browser, smoke.mjs starts Vite in middleware mode, where Vite runs inside your own Node.js process without opening a port of its own, and loads checkout.ts with ssrLoadModule. Vite's documentation says that method transforms ES module source so that Node.js can run it directly. The load therefore goes through the same plugin pipeline, vite-gleam included, that the dev server uses.

// Load src/checkout.ts through Vite's dev pipeline (vite-gleam included) and run it.
import { createServer } from "vite";

const server = await createServer({ logLevel: "error", server: { middlewareMode: true } });
const { checkout } = await server.ssrLoadModule("/src/checkout.ts");

const mug = (quantity) => ({ sku: "mug", unitCents: 1250, quantity });
const tea = { sku: "tea", unitCents: 899, quantity: 1 };

console.log(checkout([mug(2), tea], "save10"));
console.log(checkout([mug(2), tea], "FIVEOFF"));
console.log(checkout([mug(0), tea], "SAVE10"));
console.log(checkout([], ""));
console.log(checkout([mug(1)], "HALFOFF"));
await server.close();
$ node smoke.mjs
$ gleam build --target=javascript
   Compiled in 0.01s

{ ok: true, subtotal: '$33.99', discount: '$3.39', total: '$30.60' }
{ ok: true, subtotal: '$33.99', discount: '$5.00', total: '$28.99' }
{ ok: false, message: 'mug: quantity 0 is outside 1 to 99.' }
{ ok: false, message: 'Add at least one item.' }
{ ok: false, message: 'Code HALFOFF is not valid.' }
exit=0

The numbers match a hand calculation. Two mugs at 1250 plus tea at 899 is 3399 cents. save10 is trimmed and upper-cased, then takes 339 cents (339.9 truncated) off, leaving 3060, or $30.60. FIVEOFF takes a flat 500 off for 2899. The last three lines show each PricingError variant arriving as a message in the ok: false branch instead of as a thrown exception. Finally, headless Chrome (the browser running without a window) loaded the built page served by vite preview, Vite's local server for the dist/ folder, and its DOM contained:

<main><h1>Tally checkout</h1><label>Mugs <input type="number" value="2"></label><label>Code <input value="SAVE10"></label><p>Subtotal $33.99, discount $3.39, total <strong>$30.60</strong></p></main>

Back to the opening problem. Changing SAVE10 to fifteen percent now means editing one line in src/pricing.gleam, from Percent(10) to Percent(15), then updating the expected numbers in test/tally_test.gleam and running gleam test. No component is opened. If you add a new PricingError variant, the compiler points at describe_error until it is handled, and tsc points at any TypeScript caller whose types no longer match.

Check yourself

  1. **gleam test prints "Module tally_test was not found." What does it mean, and how do you fix it?** gleam test runs main from a module named <project name>_test, and this project is tally. Create test/tally_test.gleam with a main that calls gleeunit.main(), or change the project name.
  2. **What does vite-gleam do in buildStart, and what does it do in transform?** In buildStart it reads gleam.toml and runs gleam build --target=javascript. In transform it replaces each .gleam module with the compiled .mjs from build/dev/javascript/<name>/.
  3. **Why does checkout.ts import Result$isOk from ./gleam.mjs, and how does TypeScript find types for that path?** ./gleam.mjs is the Gleam prelude, which the build tool provides and which holds the Result helpers. rootDirs merges src and build/dev/javascript/tally, so the import resolves to the gleam.d.mts that Gleam wrote.
  4. **Why call Quote$Quote$total_cents(q) instead of reading q.total_cents?** The generated classes and fields are marked @deprecated; the generated accessor functions are the documented way to read Gleam data from JavaScript.
  5. **You add an ExpiredCode variant to PricingError. Which tool complains first, and where?** gleam build fails with "Inexhaustive patterns" at the case in describe_error and names ExpiredCode(code:) as the missing pattern.

Sources