Look, I get it. You’re a smart developer. You see a variable and your first instinct is to tell the compiler exactly what it is. You reach for that trusty colon and type out const name: string = 'Bob'; like a responsible adult. It feels safe. It feels explicit. It feels… redundant. Because my friend, you just wrote a string literal to a const and then told TypeScript, the tool whose entire job is to infer types, that it’s a string. You’ve become the human equivalent of a // This is a comment comment.

TypeScript’s type inference is its superpower. It’s not a suggestion; it’s the engine. Your job isn’t to manually type every single thing—it’s to write clear code and let the type system do its job, only stepping in to add constraints where necessary. Fighting this is like trying to push your car to work instead of just turning the key. Let’s learn to drive.

Let TypeScript Do the Heavy Lifting

The golden rule: if a value is assigned immediately at declaration, 99 times out of 100, you should let TypeScript infer the type. It’s more accurate and way less typing (pun intended).

// You, fighting the system (and losing)
const price: number = 42;
const message: string = `The price is ${price}`;
const items: Array<string> = ['widget', 'gadget', 'doodad'];

// You, working with the system (and winning)
const price = 42; // Inferred as the literal type 42, which is a subtype of number. More precise!
const message = `The price is ${price}`; // Inferred as string
const items = ['widget', 'gadget', 'doodad']; // Inferred as string[]

Why is the first example worse? In the “winning” version, price isn’t just a number; it’s inferred as the literal type 42. This is a more precise description of the value. This becomes crucial for narrowing later. The compiler knows more, which means it can help you more.

The Pitfall of Over-Annotating Function Returns

This is where well-meaning developers often create their own traps. Manually annotating a function’s return type is good practice for complex functions, but for simple ones, it can mask errors.

// This is a disaster waiting to happen.
function createGreeting(name: string): string {
    return Math.random() > 0.5 ? `Hello ${name}` : 100; 
    // ERROR: Type 'number' is not assignable to type 'string'.
    // Thank the gods for the annotation! It caught our bug.

    // But wait, remove the annotation and see what happens...
}

function createGreetingNoAnnotation(name: string) {
    return Math.random() > 0.5 ? `Hello ${name}` : 100;
}
// Inferred return type: string | number
// No error! The bug is now silent and hidden.

See the problem? The manual annotation acted as a safety net. Without it, TypeScript just inferred the return type as string | number and moved on, perfectly happy. Your bug is now a runtime issue. The best practice? Annotate return types on non-trivial functions. It turns the compiler into a proof-checker for your intended logic. For a one-line function that just returns a value, inference is fine. For anything with logic, annotate. Trust me.

When You Absolutely Must Step In

You don’t annotate the what, you annotate the constraint. You’re not telling TypeScript “this is a string”; you’re telling it “this thing, whatever it is, must satisfy the string interface.” This is crucial for empty structures and delayed assignment.

// The classic "empty array" problem
const dangerArray = []; // Inferred as never[] (an array that can never have elements... cool.)
dangerArray.push(1); // ERROR: Argument of type 'number' is not assignable to parameter of type 'never'.

// The solution: annotate the constraint
const safeArray: number[] = []; // I am an array that will contain numbers. It is currently empty.
safeArray.push(1); // All good.

// Similarly, for variables without an immediate value
let futureDate: Date; // I know I'm going to assign a Date to this later.
configureApp().then(() => {
    futureDate = new Date(); // Valid assignment
});
// Without the annotation, it would be inferred as 'any' and lose all type safety.

The Object Inference “Gotcha”

TypeScript’s inference on objects is brilliant but aggressively literal. It infers the most specific type possible.

const config = {
    host: 'localhost',
    port: 8080,
    retry: true
};
// Inferred type: { host: string; port: 1984; retry: true; }

function connect(options: { host: string; port: number; retry?: boolean }) {
    // ...
}

connect(config); // ERROR: Type 'true' is not assignable to type 'boolean | undefined'.

Wait, what? true isn’t assignable to boolean? Of course it is! But TypeScript inferred retry as the literal type true, not the broader type boolean. The function expects a boolean | undefined, and a literal true is too specific. The fix is to either use a type annotation on config or tell TypeScript to widen the type during assignment.

// Fix 1: Annotate the constraint
const config: { host: string; port: number; retry?: boolean } = {
    host: 'localhost',
    port: 8080,
    retry: true // Now it's widened to boolean upon assignment
};

// Fix 2: The less-known 'as const' narrowing (for when you want the opposite!)
const immutableConfig = {
    host: 'localhost',
    port: 8080,
    retry: true
} as const;
// Inferred type: { readonly host: "localhost"; readonly port: 1984; readonly retry: true; }
// Now it's *even more* specific. Useful for frozen config objects but not here.

The lesson? Understand what the compiler sees. It’s not being difficult; it’s being precise. Your job is to guide that precision toward your intent, not to brute-force it with a sledgehammer of annotations. Work with the type system, not against it. It’s your brilliant, pedantic, incredibly powerful friend.