When to annotate vs. infer types
The TypeScript rule for when to write a type and when to let the compiler infer it, plus the type-only import discipline.
Here are two snippets you’ll see in real code.
const sum: number = items.reduce((acc: number, item: Item): number => acc + item.price, 0);const isPaid: boolean = invoice.status === 'paid';// implicit-any failure — the parameter has no annotationfunction processInvoice(invoice) { return invoice.total * 1.1;}Both are wrong, for opposite reasons.
The first annotates what TypeScript already infers.
Inference reads a value’s type from the expression that produces it: sum is number from items.reduce(...), acc is number from the seed 0, and invoice.status === 'paid' is boolean.
Worse, the annotations go stale the moment the reducer changes, because the compiler trusts them and reports the wrong type instead of the mismatch.
The second hits the implicit any failure.
Under the course’s strict tsconfig, noImplicitAny rejects the untyped parameter; loosen it and the file compiles, but every property access on invoice slips past the type checker.
A parameter is the one place inference can’t help, because the value hasn’t arrived yet, so its annotation is the only contract you get.
One rule resolves both:
Annotate at the boundaries; infer everywhere else.
Boundaries are function parameters and exported APIs; inside the function, inference does the work.
One more discipline rides along: under verbatimModuleSyntax, every type-only import is marked import type so the compiler erases it cleanly.
Where annotations earn their weight
Section titled “Where annotations earn their weight”An annotation is a contract with someone who reads your signature without opening the body. If a caller relies on the signature, annotate it; if the value is local and the body explains itself, the annotation is noise. Three sites pass that test.
Function parameters
Section titled “Function parameters”Always annotate these.
TypeScript can’t infer a parameter’s type from the body, because the function might be called from anywhere; without an annotation it falls back to any (or the implicit-any error in strict mode), and every property access on the parameter goes unchecked.
const processInvoice = (invoice: Invoice) => { return invoice.total * 1.1;};The return type is inferred as number and tracks the calculation if it changes.
The parameter can’t be inferred: it sits at the edge of the function, where the value arrives from elsewhere.
Exported APIs
Section titled “Exported APIs”This covers functions, type aliases, and constants exported from a module.
The consumer reads the signature in editor tooltips, .d.ts files, and generated docs without opening the implementation, so the annotation is the export’s documentation surface and locks its public contract against silent changes from inside the body.
export const createInvoice = async ( input: CreateInvoiceInput,): Promise<Result<Invoice>> => { const parsed = createInvoiceSchema.safeParse(input); if (!parsed.success) return { ok: false, error: parsed.error };
const invoice = await db.insert(invoices).values(parsed.data).returning(); return { ok: true, value: invoice[0] };};The Promise<Result<Invoice>> return type tells the caller what to destructure: the discriminated union from the unions and intersections lesson, narrowed to a known invoice on success.
If a future edit returns a different shape, the annotation flags it at the export rather than at the call sites that depend on it.
Return types where inference produces an unintended type
Section titled “Return types where inference produces an unintended type”This case is rare.
Annotate the return type when the function exists to satisfy a wider interface, such as a callback that should return void even though its body returns a value; when the inferred return is a complex conditional the consumer shouldn’t depend on; or when a recursive function would otherwise infer any.
const findInvoice = (id: string): Result<Invoice> => { const row = invoices.find((invoice) => invoice.id === id); if (!row) return { ok: false, error: new Error(`Invoice ${id} not found`) }; return { ok: true, value: row };};Inference would give a union of the two branch shapes, which is correct but not the contract.
The explicit Result<Invoice> locks the shape so the caller can branch on result.ok and trust it.
Where inference wins
Section titled “Where inference wins”Where the compiler can read the value, let it. When the value changes the inferred type changes with it, while a hand-written annotation goes stale silently. Three sites favor inference: local variables, inline callbacks, and the return types of internal functions.
Local variables
Section titled “Local variables”Inside a function you write values the compiler can see in full, so annotating them just restates what the editor shows on hover.
const total = items.reduce((acc, item) => acc + item.price, 0);const isPaid = invoice.status === 'paid';total infers as number, isPaid as boolean.
A : number or : boolean here adds nothing and freezes the type, so the compiler can’t warn you if the reducer later returns bigint.
The exception is locking a value to a literal union, the as const and satisfies case from the previous lesson.
Inline callbacks
Section titled “Inline callbacks”When you pass an inline function to a method like .map, .filter, or .reduce, the parameter type flows from the array’s element type via contextual inference .
const prices = invoices.map((invoice) => invoice.total);const unpaid = invoices.filter((invoice) => invoice.status !== 'paid');invoice is typed for you.
Writing (invoice: Invoice) forces the type the compiler was about to derive, then drifts when the element type changes.
Over-typed .map, .filter, and .reduce callbacks are the most common annotation noise in early-career code.
Return types of internal functions
Section titled “Return types of internal functions”For a small, unexported function the inferred return type is correct and stays in sync as the body changes. Annotate the return type only when the function is exported, when inference would be wrong, or when the signature is the point.
const formatPrice = (cents: number) => (cents / 100).toFixed(2);The parameter is annotated because it is the boundary; the return type infers as string and tracks the body if the formatting changes.
The split, in one line: annotate the parameters and return types of exported functions, and let inference handle locals, intermediate values, and internal-helper returns.
The shape of a well-typed module
Section titled “The shape of a well-typed module”Picture a module as a rectangle: parameters and exports form its typed edge, inference fills everything inside.
Marking type-only imports with import type
Section titled “Marking type-only imports with import type”The boundary rule applies to imports: mark the names you use only as types, and let value imports stand.
The course’s strict tsconfig turns on verbatimModuleSyntax , which requires import type for any import used only as a type.
A bare import { ... } is preserved verbatim in the emitted JavaScript; an import type is erased entirely, so the bundler never sees it and the runtime never runs it.
import { User } from './user';
const greet = (user: User) => `Hello, ${user.name}`;User appears only in a type position, so under verbatimModuleSyntax: true this is a compile error: the compiler won’t emit a runtime import for a name that never reaches runtime.
import type { User } from './user';
const greet = (user: User) => `Hello, ${user.name}`;The compiler erases the import type: no runtime cost and no module-graph edge, since the name exists only at the type level.
When a module exports both types and values and you need both, the per-name type modifier mixes them in one statement.
import { type CreateInvoiceInput, createInvoice } from './invoice-actions';The red-marked name is the type the compiler erases; the green-marked name is the value the runtime keeps.
Reach for bare import type when every name is a type, the per-name type modifier when a statement mixes both.
Two bugs verbatimModuleSyntax prevents
Section titled “Two bugs verbatimModuleSyntax prevents”The flag requires import type because the compiler’s old habit of silently stripping imports it judged type-only produced two failures in production.
Side-effect modules silently tree-shaken
Section titled “Side-effect modules silently tree-shaken”Some modules do real work the moment they load — a side effect such as a lib/auth.ts that wires Better Auth or a db/relations.ts that declares Drizzle relations.
Importing the module is what makes it run, even when you import only a type.
Before verbatimModuleSyntax, if TypeScript judged an imported name to be type-only it stripped the whole import before emit, so the module got tree-shaken away.
The side effect never fired, and the bug surfaced at runtime far from the import that caused it.
import type { Relations } from './relations';
const fetchInvoiceWithLines = async (id: string) => { return db.query.invoices.findFirst({ where: eq(invoices.id, id), with: { lines: true } });};Here the erased import type means relations.ts never executes, so at runtime the with: { lines: true } query can’t find its relation and the error points at the query, not the missing import.
The fix: a value import alongside the type one, or a bare import './relations' for the side effect alone.
Circular type-imports that masquerade as value-imports
Section titled “Circular type-imports that masquerade as value-imports”Two modules reference each other’s types: User carries an Invoice[] field, Invoice carries a User author.
At the type level this is sound, because types have no execution order, and the checker walks the cycle without complaint.
At the value level the same cycle can deadlock initialization: module A’s top-level code runs before B’s exports exist, so a value reference into B reads undefined and crashes before your code runs.
// user.tsimport { Invoice } from './invoice';
export type User = { id: string; invoices: Invoice[];};
// invoice.tsimport { User } from './user';
export type Invoice = { id: string; author: User;};As value imports, these two files compile to a runtime cycle even though the names appear only in type positions.
Switch both to import type and the cycle vanishes: the imports are erased, and the bundler sees two independent files.
Chapter 006 covers the full module-graph mechanics; this lesson teaches the per-import-line discipline that keeps the graph honest.
Practice: annotate, infer, or import type
Section titled “Practice: annotate, infer, or import type”Sort each declaration site into the bucket where its type information belongs.
Sort each declaration site into where the type information should live — written at the boundary, left to inference, or marked as type-only on the import line. Drag each item into the bucket it belongs to, then press Check.
id parameter on export const getInvoice = (id) => { ... }cents parameter on the internal helper const formatPrice = (cents) => ...const total = items.reduce((acc, item) => acc + item.price, 0)item parameter on items.map((item) => item.price)export type Invoice = { id: string; total: number }const computeTax = (cents: number) => cents * 0.21export const createInvoice = async (input: CreateInvoiceInput): Promise<Result<Invoice>> => { ... }satisfies Record<RouteName, string> clause on export const ROUTES = { home: '/', signIn: '/sign-in' } as constimport { User } from './user' where User is only used as (user: User) => ...import { type CreateInvoiceInput, createInvoice } from './invoice-actions'External resources
Section titled “External resources”The official reference for the flag and its 'what you see is what you get' emit rule, with side-by-side examples of how each import shape is rewritten.
The opt-in flag for monorepos and libraries that need parallel `.d.ts` emit without invoking the full type checker.
Josh Goldberg on the side-effect failure modes the lesson covers, plus the two lint rules (`consistent-type-imports`, `consistent-type-exports`) that auto-fix existing codebases to the discipline.
The chapter from Matt Pocock's free Essentials book that frames the same annotate-vs-infer trade-off in long form: variable-wins, value-wins, and `satisfies` as the third option.