# Typescript

> Write TypeScript that satisfies this repo's lint and tsconfig rules, and convert an existing .js file to .ts without breaking its call sites. Use when adding or editing a .ts file, converting a .js file, adding the first .ts browser spec, or hitting a type-check or ESLint failure.

- Skill: `liberatedpixelcup/typescript` (Agent Skill)
- Install (CLI): `npx skillmds@latest add liberatedpixelcup/typescript`
- Raw SKILL.md: https://api.skillmd.com/api/skills/liberatedpixelcup/typescript/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: liberatedpixelcup (https://skillmd.com/u/liberatedpixelcup)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/liberatedpixelcup/typescript

---


# TypeScript in this repo

New code is `.ts`, including tests, scripts, and Vite plugins. Do not add new
`.js`. [`testem.cjs`](../../../testem.cjs) is the only CommonJS file.

`npm run type-check` (`tsc --noEmit`) and `npm run lint` both gate PRs via the
Lint workflow. Run `npm run type-check` and `npm run lint:fix` after any edit
under `sources/`, `scripts/`, `vite/`, or `tests/` — all four are in
`tsconfig.json` `include`. `tsc` is the `typescript` package, which this repo
aliases to `@typescript/typescript6`. `@typescript/native` is also in
`devDependencies` and is unused by workflows.

## Erasable syntax only

Node runs first-party `.ts` by type-stripping it, with no `tsx` and no compile
step. `erasableSyntaxOnly` is on, so **no enums, no namespaces, no parameter
properties**. Anything that would need to emit runtime code is a type error.

Relative imports use the extension of the file **on disk**, so a `.ts` file
importing a still-`.js` module writes `.js`:

```typescript
import { markNonExecutableLinesInLcov } from "./mark-non-executable-lines.js";
```

`allowImportingTsExtensions` is on, so importing `./foo.ts` is correct once the
target is converted. Getting this backwards is the most common failure when
converting a file — every importer has to move with it.

## tsconfig deliberately relaxes three strictest flags

[`tsconfig.json`](../../../tsconfig.json) extends `@tsconfig/strictest` and then
turns off `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, and
`noPropertyAccessFromIndexSignature`, each with a comment explaining why. Do not
re-enable them as a drive-by; pixel-index loops and metadata config bags depend
on them being off.

## Lint rules that bite

- **Unused bindings** that must exist need a `_` prefix. `argsIgnorePattern`,
  `varsIgnorePattern`, and `caughtErrorsIgnorePattern` are all `^_`.
- **`console.*` is an error except `console.error`.** Use `console.error`, or
  `debugLog` / `debugWarn` / `debugGroup` / `debugGroupEnd` / `debugTable` from
  [`sources/utils/debug.ts`](../../../sources/utils/debug.ts), which are gated
  on localhost or `?debug=`.
- [`eslint.config.js`](../../../eslint.config.js) applies
  `typescript-eslint`'s recommended preset to `**/*.ts` only, and layers
  per-directory blocks for `sources/`, `scripts/` + `vite/`, `tests/`,
  `tests/node/`, and `tests/visual/`. A converted file moves from the `.js`
  block to the `.ts` block and can pick up rules it was never subject to, so
  lint the file after renaming, not before.

## Converting a `.js` file

Four non-test `.js` files remain:

- `scripts/coverage/mark-non-executable-lines.js`
- `vite/vite-plugin-coverage-collect.js`
- [`eslint.config.js`](../../../eslint.config.js)
- [`playwright.config.js`](../../../playwright.config.js)

The rest are under `tests/`. Ordinary `*_spec.js` files are not named by
hardcoded path. These **harness** files are:

- `tests/tests.js` — named in [`tests_run.html`](../../../tests_run.html)
- `tests/bdd-globals.js` — the `mocha-globals` alias target in
  [`vite.config.ts`](../../../vite.config.ts), in two HTML import maps
  (`scripts/zip/zip-export-profile-runner.html`,
  `tests/fixtures/issue-382/issue382-golden-runner.html`), and asserted by path
  in `tests/node/scripts/generateSources/vite_config_factory_and_resolve_spec.ts`
- `tests/node/run-node-tests.js` — named in
  [`testem.cjs`](../../../testem.cjs) `before_tests`
- `tests/vitest-setup.js` — imported by `tests/tests.js`
- `tests/testem-firefox-user.js` — named in `testem.cjs`

Before renaming anything, grep for the filename. Breaking a harness path fails
a command rather than the type-check:

- `scripts/coverage/mark-non-executable-lines.js` — named in
  [`package.json`](../../../package.json) `test:node:coverage`, and imported by
  [`merge-browser-coverage.ts`](../../../scripts/coverage/merge-browser-coverage.ts).
- `vite/vite-plugin-coverage-collect.js` — imported by
  [`vite.config.ts`](../../../vite.config.ts).

Checklist for a conversion:

1. `git mv` the file, then update every importer to the new extension.
2. Update hardcoded paths in `package.json`, `testem.cjs`, HTML files, and any
   spec that asserts on the path.
3. `npm run type-check` — the file is newly checked, so expect real errors.
4. `npm run lint:fix` — it may have moved into a stricter ESLint block.
5. Run whatever command names the file, not just the test suite. A broken
   `before_tests` path or import map does not surface as a test failure.
6. Check [`codecov.yml`](../../../codecov.yml) `ignore:`. If the file is not
   ignored, it now owes covered lines: [coverage](../coverage/SKILL.md).

## Adding a `.ts` browser spec

Canvas already has several TypeScript specs (`renderer_spec.ts`,
`renderer-composite_spec.ts`, `load-images_spec.ts`, and the
`palette-recolor-*_spec.ts` files). Leftover `*_spec.js` files under
[`tests/`](../../../tests/) stay JS until converted. Two things differ from
those leftover `.js` specs:

- Register it with its **real extension**, since relative imports use the
  on-disk extension:

  ```javascript
  import "./canvas/renderer-composite_spec.ts";
  ```

- It is type-checked under `@tsconfig/strictest` as soon as it exists, which
  leftover `.js` specs are not (`checkJs` is off). Run `npm run type-check`;
  annotate DOM hosts, sinon sandboxes, and catalog handles the way
  [`renderer-composite_spec.ts`](../../../tests/canvas/renderer-composite_spec.ts)
  already does.

Everything else about writing the spec is unchanged:
[write-spec](../write-spec/SKILL.md).

