Skip to content
Chapter 4Lesson 8

Annotate the boundaries, infer the inside

The TypeScript rule for deciding when to write a type and when to let the compiler infer it, plus the type-only import discipline that keeps the module graph honest.

Here are two snippets you’ll see often 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 files are wrong, for opposite reasons.

The first restates what the compiler already knows. TypeScript reads items.reduce(...) and types sum as number on its own. acc is already a number because the seed is 0, and the comparison invoice.status === 'paid' is already a boolean. The annotations add noise the editor would have shown you anyway. Worse, they go stale the moment the reducer’s shape changes: instead of flagging the mismatch, the compiler trusts your annotation and reports the wrong type.

The second is the implicit any failure. Under the strict tsconfig the course uses, this won’t compile at all, because noImplicitAny rejects the untyped parameter. In a looser config it would compile, and every property access on invoice would slip past the type checker entirely. The parameter is the one place TypeScript cannot read the value, because the value hasn’t arrived yet. Without an annotation, the contract is missing.

Both files get the same skill wrong. The first uses annotations where they don’t earn their weight; the second skips them where they’re load-bearing. One rule resolves both:

Annotate at the boundaries; infer everywhere else.

Boundaries are function parameters and exported APIs: the places where another part of the codebase reads your signature without opening the body. Inside the function, the value is its own type and inference does the work. This lesson applies that rule to the cases you write daily, then turns to a second, smaller topic: the per-import discipline that verbatimModuleSyntax enforces, where every type-only import is marked import type so the compiler erases it cleanly.

This lesson sets the default posture for the chapter. Every code sample in the rest of the course follows the rule without comment.

A type annotation is a contract with someone who won’t read your code, and that is the test for whether you need one. If a future caller reads the signature without opening the body, the signature needs a type. If the value is local and the body is the documentation, the annotation is noise.

Three sites earn the annotation, and the rest of this section takes them one at a time.

Always annotate these. The function signature is the contract with the caller, and TypeScript cannot infer parameter types from inside the function because the function might be called from anywhere. Without an annotation, TypeScript falls back to any (or fires the implicit-any error in strict mode), and every property access on the parameter slips through the type system.

process-invoice.ts
const processInvoice = (invoice: Invoice) => {
return invoice.total * 1.1;
};

The annotation on invoice is the only thing that makes this function safe to call. The return type is inferred as number because invoice.total * 1.1 is a number, and that inference will track the calculation if it changes. The parameter cannot do the same, because it sits at the edge of the function where the value arrives from elsewhere, so that is where you draw the contract.

This covers functions, type aliases, and constants exported from a module. The consumer reads the signature in editor tooltips, in .d.ts files, and in generated API docs, all without opening the implementation. Even when inference would produce the right type, the annotation is the documentation surface for the export. Locking the return type also means a change inside the body won’t silently change the public contract.

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 is the contract. A caller reads it and knows what to destructure: the discriminated union from the unions and intersections lesson, narrowed to a known invoice on success. If a future edit changes the body to return a different shape, the annotation flags the breakage at the export, not at the seven 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, but worth naming. You reach for an explicit return type in three situations. The first is when the function’s intent is to satisfy a wider interface, such as a callback that should return void even though the body happens to return a value. The second is when the inferred return is a complex conditional the consumer shouldn’t depend on. The third is 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 };
};

Without the annotation, TypeScript would infer a union of the two branch shapes. That is technically correct, but the caller’s narrowing relies on Result<T> being the contract. The explicit type locks the discriminated union shape so the call site can branch on result.ok and trust the narrowing.

This is the other half of the rule. Let the compiler read the value when it can, and stay out of its way. Inference keeps the code concise and resilient to refactoring: when the value changes, the inferred type changes with it, whereas an annotation has to be updated by hand and goes stale silently.

Three sites favor inference, and the rest of this section walks through each of them.

Inside a function, you’re writing values the compiler can see in full. Annotating them is restating what the editor already shows on hover.

totals.ts
const total = items.reduce((acc, item) => acc + item.price, 0);
const isPaid = invoice.status === 'paid';

total is number because the seed is 0 and the reducer returns a number. isPaid is boolean because === returns a boolean. Adding : number or : boolean here is noise. Worse, it freezes the type at the declaration, so the compiler can’t warn you if the reducer’s return type later becomes bigint. The one exception is when narrowing is the point and you want to lock the type to a literal union, but at that point you’re already in the as const and satisfies territory from the previous lesson.

When you pass an inline function into a method like .map, .filter, or .reduce, the parameter type flows from the array’s element type via contextual inference . The callback inherits its parameter types from the call site that hosts it.

list-prices.ts
const prices = invoices.map((invoice) => invoice.total);
const unpaid = invoices.filter((invoice) => invoice.status !== 'paid');

invoice is typed as the element type of invoices without you writing a word. Annotating the callback parameter as (invoice: Invoice) => invoice.total works, but it forces the same type the compiler was about to derive, and if the array’s element type later widens or narrows, your manual annotation goes out of sync with the value. This is the most common annotation noise in early-career TypeScript code: a .map, .filter, or .reduce callback over-typed because the writer didn’t realize the context already provided the type.

When a function isn’t exported and the body is small, 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 function’s signature is itself the point you’re making.

format-price.ts
const formatPrice = (cents: number) => (cents / 100).toFixed(2);

The parameter has its annotation, because it is the boundary. The return type infers as string because .toFixed() returns a string, and that’s exactly what every caller in the module needs. If the formatting logic changes, the inferred type changes with it. Writing : string would not add information; it would only freeze a detail that should be free to evolve.


Annotations give you documentation and structural enforcement; inference gives you concision and resilience to refactoring. The boundary rule gives you both: contracts at the seams, freedom inside. The course’s code conventions state it in one line: annotate parameters and return types of exported functions, and let inference handle locals, intermediate values, and internal-helper returns.

One note on where this is heading. TypeScript 6.0 (March 2026) marked the isolatedDeclarations flag stable. It remains opt-in rather than a default, and monorepos and library publishers enable it so downstream tools can emit .d.ts files in parallel without invoking the full type checker. The boundary rule you’re learning here lines up with that direction.

It helps to picture the rule spatially. Imagine a function or module as a rectangle. The perimeter is where other code touches it: the parameters that flow in and the exports that flow out. The interior is where you work with values you already know. Types live at the perimeter, and inference fills the interior.

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

When you keep the perimeter typed and the inside inferred, three things follow at once. Refactoring is cheap, because the body changes and the contract doesn’t. The public surface documents itself in tooltips and generated .d.ts output, because the consumer reads the annotation directly. And the codebase reads consistently across files, because every module looks the same from the outside: a typed edge around an unannotated middle. This is the posture every well-maintained TypeScript codebase converges on.

The annotation rule covers the declaration side of the boundary, where types live in your own code. There’s a second surface: the import side, where types arrive from other modules. Both are the same posture applied to different spots, with type information at the seams and runtime values everywhere else. The annotation rule says to mark the contracts you publish; the import rule says to mark the names you only consume as types.

The strict tsconfig the course’s projects use turns on verbatimModuleSyntax . With that flag on, every import that’s used only as a type must use import type.

Under the flag, the compiler emits imports and exports exactly as written. There’s no behind-the-scenes guessing about which imports were “really” type-only and could be stripped. import type declarations are erased entirely: the compiler emits nothing, the bundler never sees them, and the runtime never executes them. Bare import { ... } declarations are preserved verbatim in the emitted JavaScript. You decide which mode each import is in, and the syntax makes that choice visible.

greet.ts
import { User } from './user';
const greet = (user: User) => `Hello, ${user.name}`;

Under verbatimModuleSyntax: true, this is a compile error. The User name is only used in a type position, but the import is a value import, and the compiler refuses to emit a runtime import for a name that never reaches runtime.

When a module exports both types and values and you need both, you don’t write two imports. The per-name type modifier lets you mix them in one statement.

create-invoice-form.ts
import { type CreateInvoiceInput, createInvoice } from './invoice-actions';

The red-marked name is the type-only import the compiler erases from the emitted JavaScript; the green-marked name is the value the runtime keeps. Both forms are part of the everyday vocabulary: bare import type for purely typed imports, and per-name type markers for mixed imports. Learn to read them at a glance, since the rest of the course writes them without comment.

Why the strict tsconfig forces the discipline

Section titled “Why the strict tsconfig forces the discipline”

So far, import type looks like syntax with extra steps. The strict tsconfig requires it because the old behavior, where the compiler quietly stripped imports it thought were type-only, produced two specific failure modes. They caused enough trouble in production that the language flag exists to prevent them. Once you see them, the reason for the discipline becomes clear.

Some modules do real work at load time. They register handlers, configure a singleton, or run an initialization step that downstream code relies on. Common cases include a lib/auth.ts that wires Better Auth on import, a db/relations.ts that declares Drizzle relations, and a feature-flag module that registers itself with a global registry. Importing these modules, even for nothing more than their type exports, is what makes them run.

Under the pre-verbatimModuleSyntax behavior, TypeScript would inspect each import and, if it could prove the imported name was used only at the type level, silently strip the whole import statement before emit. The bundler then never saw the module, so the side effects never fired. Your Better Auth client called into an unconfigured singleton, or your Drizzle relations were never registered, and the bug surfaced only 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 } });
};

The import type { Relations } line is erased before the bundler runs, so the relations.ts module’s top-level relation declarations never execute. At runtime, the with: { lines: true } query can’t find the relation it depends on, and you get a confusing error that points at the query rather than at the missing import. The fix is to add a value import alongside the type one, or to add a bare import './relations' that exists only for the side effect.

verbatimModuleSyntax removes the guess. Every bare import preserves the module-graph edge, and every import type declaration states your intent explicitly: this name never reaches runtime, and you aren’t asking for the module’s side effects either. When you genuinely need the side effect, you write the value import or the bare side-effect import. The discipline keeps the choice visible.

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, and Invoice carries a User author field. At the type level, this is a sound mutual reference: the type checker walks the cycle without complaint, because types have no execution order.

At the value level, the same cycle can deadlock module initialization. Module A’s top-level code runs before module B’s exports are defined, so any value reference into B reads undefined. The result is a runtime crash with a confusing stack trace. Module-graph cycles are among the harder bugs to localize, because the failure happens during initialization, before your code starts running.

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;
};

Written as value imports, import { Invoice } and import { User }, the two files compile to a runtime cycle even though the names are used only in type positions. Switch both sides to import type, and the cycle vanishes from the emitted JavaScript: the import statements are erased, the bundler sees two independent files, and the type-level mutual reference stays where it belongs.

The full module-graph mechanics, including live bindings, depth-first evaluation, dynamic import(), and the "use client" boundary, are covered in chapter 006. This lesson teaches the per-import-line discipline that keeps the module graph honest.

Now you’ll build the muscle memory. The exercise below shows ten declaration sites, including function parameters, locals, exports, and imports, and asks you to sort each into the right bucket. By the end, “annotate, infer, or import type” should be a quick, automatic decision for every line you write.

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'