A common newcomer mistake in TypeScript: annotating everything. Every variable, every constant, every return type, every callback parameter. The result is a codebase that's twice as long as the JavaScript it replaced and harder to refactor because every annotation is one more place that can drift from reality.

Inference is the antidote. The compiler can usually figure out the type from the value — and when it can, manual annotations only add noise.

Inference Keeps Code Close To The Source Of Truth

Consider this:

TypeScript src/services/pricing.ts
const taxRates = {
  CA: 0.0725,
  NY: 0.04,
  MN: 0.06875,
} as const;

function calculateTax(state: keyof typeof taxRates, amount: number): number {
  return amount * taxRates[state];
}

Two things to notice. First, as const makes taxRates not just an object with string keys, but the exact literal type { readonly CA: 0.0725; readonly NY: 0.04; readonly MN: 0.06875 }. Second, keyof typeof taxRates derives the union "CA" | "NY" | "MN" from the values themselves. Add a state, and the type updates. Remove one, and every call site that referenced it stops compiling.

If you'd written state: string, the contract would have silently allowed 'XX' and crashed at runtime when accessing taxRates['XX']. Inference is doing real work.

Manual Types Can Drift

This is a real bug pattern in long-lived TypeScript codebases:

TypeScript
type User = {
  id: string;
  email: string;
};

const defaultUser: User = {
  id: '0',
  email: 'guest@example.com',
};

// later, someone adds a field to User but forgets to update defaultUser
type User = {
  id: string;
  email: string;
  name: string; // new
};
// defaultUser now violates the type, but only if it's referenced from a context that needs `name`

If defaultUser had been written const defaultUser = { id: '0', email: 'guest@example.com' } as const, you'd get a clear compile error the moment anything tried to use it as a User after the schema grew. The truth lives in one place.

Use Explicit Types At Boundaries

Inference is great for what's inside your code. Annotations earn their rent at the boundaries — places where the type is part of a contract:

  1. Function parameters. The compiler can't infer what callers will pass. Annotate.
  2. Public/exported function return types. Stable contracts protect callers from accidental changes inside.
  3. Empty arrays and objects. const items = [] infers any[]. const items: User[] = [] doesn't.
  4. Complex unions. let status = 'idle' infers string. let status: Status = 'idle' keeps the union.

Inside the function body, let inference do its thing. Outside, declare the contract.

Inline two-column guide listing six places to annotate explicitly (function parameters, public APIs, empty arrays, complex unions, generic constraints, exported types) and six places to let inference work (local variables, simple returns, literals, as const, callbacks, async returns).
Annotate the boundaries · let inference handle the inside.

satisfies Is Inference Plus Validation

The satisfies operator is the modern answer to "I want the inferred type and I want to check it matches a contract." Before satisfies, you'd choose: annotate (lose specificity) or skip (lose validation).

TypeScript src/config/routes.ts
type RouteMap = Record<string, { path: string; auth: boolean }>;

const routes = {
  home:    { path: '/',      auth: false },
  account: { path: '/me',    auth: true  },
  admin:   { path: '/admin', auth: true  },
} satisfies RouteMap;

routes.home.path; // typed as the literal '/'
routes.foo;       // compile error — 'foo' isn't a key

The shape is checked against RouteMap, but the actual inferred type keeps every literal value. You get autocomplete on routes.home.path knowing it's literally '/', not just string.

Let Implementation Details Breathe

A useful test before adding an annotation: would removing it cause a real loss of safety, or just a different formatting?

If removing it changes nothing about the inferred type, it's noise. If removing it widens the type or hides a bug class, it's earning its rent.

The result of following that rule: TypeScript code that reads like JavaScript with strategically placed types — not Java with extra symbols.

Pro Tips

  1. Use as const aggressively for config objects, route maps, status enums — anything where literal types matter.
  2. Use satisfies when you want both validation and the inferred narrow type.
  3. Annotate function parameters and exported return types. Skip return types on private one-liners.
  4. Beware empty literals. [] and {} infer too widely; annotate when you know what's coming.
  5. Trust the compiler — but read its hover. If the inferred type is wrong, the value is wrong, not the inference.

Final Tips

The TypeScript codebases I've enjoyed working in the most all share one trait: they trust inference. The annotations are deliberate, sparse, and meaningful. Reading a function feels like reading the logic, not parsing through twenty : string declarations.

The compiler is a quiet collaborator. Let it do the typing where it can. Save your annotations for the moments where the contract really needs to be visible.

Good luck — and may your inferred types do the work for you 👊