Formatting & Project Hygiene
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Formatting and project hygiene are how a TypeScript team makes the boring parts disappear. The goal is not prettier-looking code for its own sake; the goal is fewer review comments about commas, fewer "works on my machine" setup gaps, and a faster path from edit to checked code.
This is another tool-shaped lesson. The examples are read-only project files and scripts, not a playground.
Prettier or Biome: end the style debate
A formatter is a peace treaty. You choose the tool once, put it in scripts and editor settings, and stop spending review energy on line breaks.
Prettier is the classic choice: an opinionated formatter with intentionally few options. That is the point. If a team can tune every tiny layout decision, the style debate simply moves from pull requests into formatter config.
Biome can fill the same formatting role, and it can also lint and organize imports. Section 17.1 already drew the boundary: Biome is excellent as a fast hygiene tool, while typescript-eslint may still stay in the stack for checker-backed policy rules.
Pick one formatter path per repo. These are reference script shapes, not a request to install both:
{
"scripts": {
"format": "prettier . --write",
"format:check": "prettier . --check",
"hygiene": "biome check --write .",
"hygiene:check": "biome check ."
}
}In a Prettier-based project, format and format:check usually become the scripts people use. In a Biome-based project, hygiene and hygiene:check may cover formatting, linting, and import organization together.
The important habit is consistency. Run the write command locally. Run the check command in CI. Do not let a pull request become the place where the team renegotiates indentation.
Import sorting and type-only imports
Imports are code, but they are also a table of contents. A messy import block makes a file feel noisier than it is.
Import organization has two separate jobs:
- Sort and group import declarations so the top of the file stays predictable.
- Mark imports used only as types with
import type, so value imports and erased type imports are visibly different.
Reference code:
import { renderMarkdown } from "./markdown";
import { formatTitle } from "./course";
import type { Course, Lesson } from "./course";
type RenderInput = {
course: Course;
lesson: Lesson;
};
function renderLesson(input: RenderInput): string {
const title = formatTitle(input.course, input.lesson);
return renderMarkdown(`# ${title}`);
}formatTitle and renderMarkdown are runtime values. They must exist in the emitted JavaScript. Course, Lesson, and RenderInput are type-layer names. They disappear before runtime.
Section 9 introduced verbatimModuleSyntax: imports without a type modifier stay in JavaScript, and imports with a type modifier are dropped. That makes import type a real hygiene signal, not just decoration.
If you use typescript-eslint, the usual rule is:
export default [
{
files: ["**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports"
}
]
}
}
];With Biome, import organization can be part of biome check. With Prettier alone, formatting does not imply import sorting; pair it with your editor, ESLint setup, or another import organizer deliberately. Do not leave import order as a reviewer preference.
EditorConfig and line endings
Some setup choices are too basic to belong to any one formatter: final newline, line endings, indentation width, charset. EditorConfig is a small file format many editors understand for those cross-editor defaults.
Reference file:
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = falseThis file does not replace Prettier or Biome. It catches the quiet workspace-level settings that otherwise drift: one teammate saves CRLF line endings, another trims Markdown line breaks, another uses tabs because their editor default says so.
The Markdown exception is intentional. In Markdown, trailing spaces can be meaningful for line breaks. Hygiene files should encode the team's real conventions, not blindly apply one rule everywhere.
Runtime and package-manager conventions
TypeScript tooling runs on Node, so your Node version is part of the workshop. A pinned dependency graph helps, but it does not fully answer "which Node should run these scripts?"
For teams that use nvm, .nvmrc is the lightweight convention:
22That says "use a Node 22 release for this project." A teammate with nvm can run:
nvm use.
If your team uses a different version manager, use its equivalent file instead. The point is not that .nvmrc is universal. The point is that the repo should state its runtime expectation somewhere boring and versioned.
You can also make package metadata say the same thing:
{
"engines": {
"node": ">=22 <23"
},
"packageManager": "npm@10.9.0"
}Treat these as setup signposts. Different package managers and hosting platforms enforce them differently, so do not oversell them as perfect guards. They are still useful because they turn "what version are you on?" into a file you can inspect.
Fast pre-commit hooks
A pre-commit hook should be a speed bump, not a second CI. If it takes long enough for people to resent it, they will bypass it.
Keep hooks focused on staged files and mechanical fixes:
{
"scripts": {
"precommit": "lint-staged",
"quality": "npm run format:check && npm run lint && npm run typecheck && npm run test:run"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": "prettier --write"
}
}Whatever hook runner your project uses can call npm run precommit. The useful part is that lint-staged works on files staged for the commit, so formatting and local lint fixes stay proportional to the change.
Keep full-project checks out of the hook unless the project is small enough that they remain instant. tsc --noEmit, the full runtime test suite, and production builds belong in CI and in an explicit quality command you run before opening a serious pull request.
The professional shape is simple: format on save, organize imports automatically, record editor and Node conventions in files, run tiny staged-file hooks, and let CI do the heavy full-project proof.
Next lesson moves from static hygiene to debugging: reading TypeScript stack traces, source maps, and the gap between TypeScript source and emitted JavaScript.
Checkpoint
Answer all three to mark this lesson complete