Skip to content
Chapter 4Lesson 8

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.

over-annotated.ts
const sum: number = items.reduce((acc: number, item: Item): number => acc + item.price, 0);
const isPaid: boolean = invoice.status === 'paid';
under-annotated.ts
// implicit-any failure — the parameter has no annotation
function 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.

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.

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.

process-invoice.ts
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.

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.

invoice-actions.ts
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.

find-invoice.ts
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 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.

Inside a function you write values the compiler can see in full, so annotating them just restates what the editor shows on hover.

totals.ts
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.

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 .

list-prices.ts
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.

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.

format-price.ts
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.

Picture a module as a rectangle: parameters and exports form its typed edge, inference fills everything inside.

Exports
Annotate
Internal body
Infer
  • locals
  • inline callbacks
  • internal helper returns
Parameters
Annotate
Types live at the module's perimeter; inference fills the interior.

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.

greet.ts
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.

When a module exports both types and values and you need both, the per-name type modifier mixes them in one statement.

create-invoice-form.ts
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.

The flag requires import type because the compiler’s old habit of silently stripping imports it judged type-only produced two failures in production.

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.

queries.ts
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.ts and invoice.ts
// user.ts
import { Invoice } from './invoice';
export type User = {
id: string;
invoices: Invoice[];
};
// invoice.ts
import { 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.

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.

Annotate The declaration site is the contract.
Infer Let the compiler read the value.
Type import The name never reaches runtime.
The id parameter on export const getInvoice = (id) => { ... }
The cents parameter on the internal helper const formatPrice = (cents) => ...
The type of const total = items.reduce((acc, item) => acc + item.price, 0)
The item parameter on items.map((item) => item.price)
The shape of export type Invoice = { id: string; total: number }
The return type of the internal helper const computeTax = (cents: number) => cents * 0.21
The return type on export const createInvoice = async (input: CreateInvoiceInput): Promise<Result<Invoice>> => { ... }
The satisfies Record<RouteName, string> clause on export const ROUTES = { home: '/', signIn: '/sign-in' } as const
import { User } from './user' where User is only used as (user: User) => ...
import { type CreateInvoiceInput, createInvoice } from './invoice-actions'