API Docs

Hammerspoon 2 configs are plain JavaScript, but nothing stops you writing them in TypeScript instead and compiling down to the .js file Hammerspoon actually loads. The project ships a hammerspoon.d.ts file with every module, type, and method from the API reference declared as ambient globals, so hs, console, HSRect, and the rest are all typed without any import statements — the same way they're available at global scope when Hammerspoon runs your init.js. If you haven't written a Hammerspoon 2 config at all yet, start with the Getting Started guide instead; this guide assumes you already know the runtime (object lifecycle, require(), hotkeys, hs.ui) and just want type checking on top of it.

Why bother?

  • Autocomplete for every module and type, in any TypeScript-aware editor.
  • Compile-time errors for typos (hs.windw) and wrong argument types/counts, instead of finding out when the callback fires.
  • Inline documentation — the same - Parameter:/- Returns: text from the API reference shows up as hover tooltips, without switching to a browser.

None of this changes what runs at the other end: Hammerspoon's JavaScriptCore engine has no idea TypeScript exists. You compile .ts to .js yourself, and only the compiled output is ever loaded.

Setup

1. Install TypeScript

In your config directory (hs.appinfo.configDir, typically ~/.config/Hammerspoon2/):

cd ~/.config/Hammerspoon2
npm init -y
npm install --save-dev typescript

2. Get the type definitions and a tsconfig

Every published version of these docs carries the exact hammerspoon.d.ts and tsconfig.example.json that match it, one directory up from this page, so the links below always point at files for the version you're reading right now:

Save the first as hammerspoon.d.ts and the second as tsconfig.json, both directly in your config directory. Re-download hammerspoon.d.ts whenever you update Hammerspoon 2, since module APIs can gain methods or change shape between versions — hs.docs.show() in the running app's console always matches your installed version exactly, if you'd rather not guess which docs version that corresponds to on this site.

Everything hammerspoon.d.ts declares is also browsable as rendered documentation, in actual TypeScript syntax rather than the API reference's JS-flavored view — useful for seeing the real overload signatures and member types at a glance: the TypeScript API reference. To read the TS docs using the in-app viewer, call hs.docs.show(null, true).

The example tsconfig's important choices: module: "commonjs" (matching how Hammerspoon's own require() works), an outDir of ./compiled so compiled output stays out of the way, and strict mode on. Point its include at wherever your .ts files actually live:

{
  "include": ["*.ts", "src/**/*.ts"]
}

3. Write your config in TypeScript

Create config.ts:

// Show an alert when Hammerspoon loads
hs.ui.alert("Hammerspoon loaded!")

// Bind a hotkey with type checking
hs.hotkey.bind(["cmd", "alt"], "r", () => {
    console.log("Reloading config...")
    hs.reload()
}, null, null)

// Work with windows with autocomplete
const win = hs.window.focusedWindow()
if (win) {
    const frame = win.frame
    console.log(`Window size: ${frame.w} x ${frame.h}`)
}

Nothing here differs from the plain-JS version in the Getting Started guide — the same object lifecycle rule applies, hotkeys and timers still need a kept reference, and hs.ui is built the same chained way. TypeScript only adds the type layer on top; it doesn't change how any of these APIs behave.

4. Compile and load it

npx tsc

This produces compiled/config.js. In your init.js — which always stays plain JavaScript, since it's what Hammerspoon loads directly — require the compiled output:

require("./compiled/config.js")

Remember require()'s own rule from the Getting Started guide: the leading ./ is mandatory for local files, and init.js is the one file whose top level runs at true global scope — so if config.ts sets up hotkeys or timers, requiring it from init.js is what keeps them alive long enough to matter, exactly as if that code had been written directly in init.js.

Development workflow

Watch mode

npx tsc --watch

Recompiles config.js every time you save config.ts. It won't reload Hammerspoon itself — pair this with a reload hotkey (hs.reload()) or the menu bar's "Reload Config" so you can pick up each recompiled build.

npm scripts

{
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch",
    "clean": "rm -rf compiled"
  }
}

Splitting a TypeScript config across multiple files

This works exactly like the plain-JS version covered in Splitting a growing config into multiple files — require() doesn't know or care whether the file it's loading started out as .ts, only that the compiled .js exists on disk. A common structure:

config/
  src/
    window-management.ts
    hotkeys.ts
  compiled/
    window-management.js
    hotkeys.js
  init.js
// src/window-management.ts
export function centerFocused(): void {
    const win = hs.window.focusedWindow()
    if (win) win.centerOnScreen()
}
// init.js
const { centerFocused } = require("./compiled/window-management.js")
hs.hotkey.bind(["cmd", "alt"], "c", centerFocused, null, null)

Note the plain export/no-import mix here: window-management.ts uses TypeScript's own export syntax (compiled by tsc, with module: "commonjs", into the same module.exports shape require() expects), while anything reaching into hs itself needs no import at all — hammerspoon.d.ts declares those as ambient globals, available everywhere once it's on your TypeScript include/files list.

Editor setup

VS Code

Works automatically — just open your config directory and start editing .ts files. Recommended extensions: ESLint, Prettier.

WebStorm / IntelliJ

TypeScript support is built in; open the folder and go.

Vim / Neovim

Use a TypeScript language server plugin — coc.nvim with coc-tsserver, ALE with TypeScript support, or vim-lsp with typescript-language-server.

Troubleshooting

"Cannot find name 'hs'"

hammerspoon.d.ts needs to be visible to the compiler. If it's sitting in your config directory alongside tsconfig.json, TypeScript's default include picks it up automatically as long as its extension (.d.ts) is included by the include glob you're using, or list it explicitly:

{
  "files": ["hammerspoon.d.ts"],
  "include": ["*.ts"]
}

"Cannot find module" after compiling

This is almost always the same require() path rule from the Getting Started guide, not a TypeScript problem: require("./compiled/config.js"), not require("compiled/config.js") — a bare specifier without .//../ is treated as a lookup for a named package, not a sibling file.

Compilation errors from third-party code

If strict mode is fighting code you don't control (a vendored file, a generated file), relax it for just that corner rather than globally — either exclude the file from include, or wrap the offending lines with an // @ts-expect-error comment, rather than turning strict off in tsconfig.json.

Benefits in action

// Plain JS: no autocomplete, no type checking
hs.windw.focusedWindow() // Typo! Only fails at runtime, inside the callback that uses it.
// TypeScript catches the typo at compile time, before you ever reload Hammerspoon:
hs.windw.focusedWindow()
//    ~~~~~ Error: Property 'windw' does not exist on type...
//          Did you mean 'window'?

hs.window.focusedWindow() // Correct — and autocompleted for you.

Learn more

If you hit an issue with the type definitions themselves (a missing method, a wrong type), please open an issue on the Hammerspoon 2 repository — they're generated directly from the Swift source, so a gap there usually means a doc-comment gap in the module itself.