Home / Articles

What TypeScript's Compiler Catches That JavaScript Lets Slip Through

Compare JavaScript and TypeScript side by side: type inference, annotations, primitives, any, unions and typed functions, plus what tsc really flags and emits.

Publisher: REACTAPP.TOP14 min read3122 words

This article is published in English.

Sources: JavaScript vs TypeScript: Understanding Type Safety

JavaScript runs almost anything you give it, which is exactly why some bugs only surface in production. TypeScript adds a checking step before your code runs, but it is easy to misunderstand what that step does and does not protect you from. This guide works through small, typeable examples, from reassigning variables to typed functions, so you can see precisely where JavaScript stays silent, where the TypeScript compiler speaks up, and how to configure it so errors actually block a build.

Dynamic typing versus static typing

JavaScript is dynamically typed. A variable has no fixed type; the type belongs to whatever value it currently holds, is determined while the program runs and can change at any moment.

TypeScript is a statically typed superset of JavaScript. Every valid JavaScript program is also syntactically valid TypeScript, but TypeScript adds a type checker that analyses your code at compile time, before anything executes. The checker then removes the type information and outputs plain JavaScript.

Everything else in this guide follows from that single difference. Deciding types at runtime makes JavaScript flexible but lets mistakes through unnoticed. Fixing types up front makes TypeScript stricter but surfaces many mistakes early, which is what people mean when they call TypeScript type safe. Strictly speaking, that safety is a compile-time guarantee: it holds only as far as the checker can see.

When a variable changes type

In plain JavaScript, start with a numeric variable and check its type with typeof:

let age = 30;
console.log(typeof(age));  // output is number

A few lines later, nothing prevents you from storing a string in the same variable:

age = "John";
console.log(age);
console.log(typeof(age)); // string

JavaScript accepts this without warnings or errors. The first typeof reports "number" and the second reports "string": one variable, two unrelated types over its lifetime. In a small script that is harmless; in a large codebase, any code still expecting a number from age will now misbehave far from the line that caused it.

TypeScript refuses the equivalent code:

let data = 2;
data = "two"; // ❌ Error: Type 'string' is not assignable to type 'number'

When TypeScript sees let data = 2; it infers from the initial value that data has the type number, and it keeps that type for the variable's whole life. This behaviour is called type inference. The assignment of "two" on the next line is therefore rejected by the compiler before the program ever runs.

Instead of relying on inference, you can state the type yourself:

let data: number = 2;

The : number part is TypeScript-only syntax and does not exist in JavaScript. For type safety the two forms are equivalent; the annotated version simply makes the intent visible to anyone reading the code later, including you in six months.

The rule to remember: JavaScript lets a variable switch types as often as you like, while TypeScript fixes a variable's type once, either from your annotation or from its first value, and enforces it from then on.

The string-plus-number trap

The previous example shows that TypeScript checks types. The next one shows why that matters.

JavaScript again happily lets a variable that held a string be overwritten with a number:

let message = "Hello";
let num = 2;
message = 20; // totally allowed in JS

Now the classic surprise:

let result = "5" + 3;
console.log(result); // "53", not 8

You might expect 8, because "5" looks numeric. But "5" is a string, and when the + operator sees a string on either side, JavaScript performs string concatenation instead of arithmetic. It converts 3 to text and joins the two, producing "53". JavaScript runs this without complaint because it never checks types in advance; it simply does what the code literally says at the moment it runs.

What TypeScript does with the same code

Here is the TypeScript version, with the types spelled out:

let num1: string = "5";
let num2: number = 3;

And the addition itself:

let add = num1 + num2; // expected 8, but num1 is a string and num2 is a number
console.log(add); // actual result is 53

The printed value is still 53. This is where many explanations go wrong, so it is worth being precise. TypeScript deliberately allows string + number, because concatenating text with a number is common and legitimate JavaScript. The checker does not reject the expression; it infers that add has the type string.

The operator error you may have seen looks like this:

Operator '+' cannot be applied to types 'string' and 'number'.

TypeScript reports this kind of message for operand combinations it does consider meaningless, for example adding a boolean to a number, or adding values whose types have nothing in common. It is not what you get for num1 + num2 above.

Where TypeScript does protect you in this scenario is the moment the result is used as a number. If you had written let add: number = num1 + num2;, the compiler would report that type string is not assignable to type number. Likewise, passing add to a function that expects a number would fail. In practice this is how the "5" + 3 bug gets caught in a typed codebase: not at the plus sign, but wherever the accidental string meets code that was promised a number.

Why errors do not stop your program by default

There is a second subtlety. Even when tsc does find a type error, by default it still emits the compiled .js file. Running that output with node executes plain JS, nothing more. All type information has been erased, and only the runtime logic remains.

This is the essence of TypeScript: it does not change how your code behaves at runtime. It reports problems in your editor and during compilation, before you run anything. If you want the compiler to refuse to produce output whenever there are type errors, enable this option in tsconfig.json:

{
  "compilerOptions": {
    "noEmitOnError": true
  }
}

With noEmitOnError turned on, code with type errors never reaches node, because tsc stops without writing the JavaScript file. In most real projects the same effect is achieved in CI by running tsc --noEmit as a separate type-check step and failing the build on any error.

The key point: TypeScript's value is not different runtime behaviour, because compiled TypeScript is JavaScript. Its value is catching mismatches before you press run, giving you a chance to fix bugs JavaScript would silently ship.

Annotations and inference

These two terms describe opposite ways of arriving at the same result. Consider an annotated declaration:

let age: number = 30;

Taken piece by piece:

  • let is the declaration keyword
  • age is the variable name
  • 30 is the assigned value
  • number is the type
  • : number, the colon followed by the type, is the type annotation

An annotation tells TypeScript explicitly which type a variable has, using colon syntax such as : number, : string or : boolean. Inference is the reverse: you omit the type and TypeScript deduces it from the assigned value.

let age = 30;        // inference — TS decides this is a "number" on its own
let city: string = "Delhi"; // annotation — you told TS explicitly

Both give the same protection, because once the type is set, by you or by the compiler, it is locked. Annotations are mainly about clarity. They are most useful when a variable is declared without an initial value, since there is nothing to infer from, and on function signatures and public APIs, where they document intent. For local variables with an obvious initial value, most teams let inference do the work.

Primitive and non-primitive types

A helpful way to group the types you will meet early on:

  • Primitive types such as number, string, boolean, undefined and null each hold exactly one value at a time.
  • Non-primitive, or structured, types such as arrays, objects, tuples, functions, and shapes described by classes and interfaces combine multiple values, such as an array of scores or a user record with a name and an email.

A primitive is one box holding one item; a structured type is a crate that holds many boxes.

Two more constructs usually appear in beginner lists next to the primitives, and they are covered below: any and union types. Strictly, neither is a primitive. any is an escape hatch that disables checking, and a union is a way of combining other types. They sit alongside the primitives because they are typically used with single values.

Primitive types in practice

Numbers

TypeScript's number covers integers and floating-point values alike. There are no separate int or float types as in many other languages, because JavaScript itself stores ordinary numbers as 64-bit floating-point values.

let age = 20;
let price = 26.2;
let big = 24757689;

Printing them and checking one with typeof:

console.log("Age :", age);
console.log("price:", price);
console.log(big);
console.log(typeof(age)); // "number"

Whole numbers, decimals and large values are all simply number, and typeof reports "number" for each of them. For integers beyond the safe range of that floating-point format, JavaScript offers a separate bigint type.

Strings

A string is a sequence of characters: a word, a sentence or any text. You can write string literals with single quotes, double quotes or backticks.

let firstname: string = 'JOHN';
let lastname: string = "SINGH";
console.log("Hello", firstname, lastname); // Hello JOHN SINGH

Passing several comma-separated arguments to console.log prints them separated by spaces, which is why this prints the full name. A related line, however, does not do what it appears to do:

// let greeting: string = "Hello", firstname, lastname; // ❌ this does NOT concatenate

Inside a let statement a comma separates multiple declarations; it does not join text. This line declares three separate variables, greeting, firstname and lastname, and only greeting gets a value. If firstname and lastname already exist in the same scope, TypeScript will also complain that they are being redeclared.

So commas work for printing several values, but to store a combined sentence in a variable, use a template literal: a string wrapped in backticks, with ${} placeholders for expressions.

let firstname: string = 'JOHN';
let lastname: string = "SINGH";
let greeting: string = `Hello ${firstname} ${lastname}`;
console.log(greeting); // Hello JOHN SINGH

Template literals are the recommended way to mix text and values. They read more cleanly than chains of + concatenation and also support strings spanning several lines.

Booleans

A boolean has exactly two possible values, true and false.

let isStudent: boolean = true;
let hasJob: boolean = false;

Logging one and checking its type:

console.log("Is student?", isStudent); // true
console.log(typeof (isStudent)); // "boolean"

Booleans drive every conditional in your code: if statements, loop conditions and anywhere else a yes-or-no decision is made.

Null and undefined

Both null and undefined represent the absence of a value, but they carry different intent. Start with a variable that is declared and never assigned:

let emptyvalue;
console.log(typeof emptyvalue); // "undefined"

A declared but unassigned variable holds undefined, meaning the variable exists but nothing has been stored in it yet.

You can also give variables the null and undefined types explicitly:

let emptyvalue: null = null;
let value: undefined = undefined

Then compare how each one prints, along with a typed variable that has not been assigned:

console.log(emptyvalue); // null
console.log(value);      // undefined
let price: number;
console.log(price); // undefined - declared, but not yet assigned any value

The difference in plain terms:

  • undefined means a variable exists but has not been given a value; it is the default empty state.
  • null means a developer deliberately set the variable to "no value"; it is intentional, explicit emptiness.

A type annotation such as : number restricts what a variable may hold later; it does not supply an initial value. At runtime, price therefore prints undefined. Note, though, that with strict mode enabled, which is the norm in modern projects, TypeScript reports an error on that console.log(price) line because the variable is used before being assigned. That check is one more example of the compiler catching a mistake plain JavaScript would allow.

In everyday code you will rarely annotate a variable as purely null or undefined; these examples exist to show how the two empty states behave. Far more common is a union such as string | null for a value that may be missing.

The any type: switching the checker off

Use any rarely and deliberately, because it turns off the very checks you adopted TypeScript for. An any variable accepts every kind of value and can be reassigned to a different type at will, exactly like plain JavaScript.

let value: any = "abcd";
console.log(typeof value); // "string"

Reassigning it to a number and then a boolean:

value = 123;
console.log(typeof value); // "number"
value = true;
console.log(typeof value); // "boolean"

The variable moves from string to number to boolean without any objection, because the annotation told the compiler not to check it.

There are legitimate uses. When you truly cannot know a type in advance, for instance with unpredictable external data, or during an incremental migration from JavaScript when typing everything at once is not practical, any keeps you moving. Relying on it widely, however, costs you autocomplete, compile-time errors and the safety net that motivated using TypeScript in the first place. Treat it as a last resort, not a habit. For data of unknown shape, unknown is usually the better choice, because it forces you to check the value before using it. Our guide to replacing any with type-safe patterns covers the alternatives in depth.

Union types: a controlled set of options

A union type allows a value to be one of several specific types. Unlike any, it does not admit everything, only the types you list. "Union" is not a keyword; it is the name of the pattern, written with the pipe symbol |.

let id: number | string | boolean;

Assigning each allowed type in turn:

id = "123abc";
console.log(id);
id = 12;
console.log(id);
id = true;
console.log(id);

The variable id may hold a number, a string or a boolean and nothing else. Assigning an array such as [1, 2, 3] is rejected immediately, because arrays are not part of the union. This gives you flexibility exactly where it is needed; real-world identifiers, for example, sometimes arrive as numbers and sometimes as strings, while keeping a firm boundary around what is allowed. Before you use a union value in a type-specific way, TypeScript expects you to narrow it, for example with a typeof check.

Typing functions

Functions package a single task into code you can reuse. You define it once and call, or invoke, it wherever and however often you need it.

A function without parameters

The simplest version hard-codes its values:

function sum() {
  console.log(10 + 20);
}

Defining the function does not run it. Nothing happens until you call it:

sum(); // calling the function → prints 30

That separation is the point: write the logic once and execute it on demand.

Adding typed parameters

Functions become genuinely reusable once they accept parameters, placeholders that let the same code operate on different values per call.

function sum(x: number, y: number) {
  console.log(x + y);
}

Calling it with two numbers:

sum(10, 20); // prints 30

Now sum is no longer tied to 10 and 20; sum(5, 7) or sum(100, 1) work just as well. The : number after each parameter is a type annotation on the parameter, so TypeScript rejects a call such as sum("hello", 20) before the code runs. This is where TypeScript typically pays for itself: at function boundaries, where one part of a program hands data to another.

This version still only prints its result with console.log. It gives nothing back to the caller, which matters for the next step.

Declaring a return type

A function that returns its result is far more useful:

function sum(x: number, y: number): number {
  return x + y;
}

Capturing and using the returned value:

let res: number = sum(10, 20);
console.log(res); // 30

Three things changed compared with the previous version:

  • return x + y; hands the value back to the caller instead of printing it inside the function.
  • The : number after the parameter list, in (x: number, y: number): number, is the return type annotation. It promises that the function always returns a number, and returning a string would be a compile error.
  • Because sum now returns a value, let res: number = sum(10, 20); can store it for later use: logging it, passing it to another function or combining it with other results.

A function that returns its result can be composed into other logic; one that only logs is a dead end whose output cannot be reused. TypeScript can often infer return types, but writing them explicitly on exported functions guards against accidentally changing what a function returns.

Wrapping up

  • JavaScript determines types at runtime and trusts you completely, which is flexible but allows silent bugs such as "5" + 3 producing "53".
  • TypeScript fixes each variable's type through annotation or inference and checks it at compile time, before code runs.
  • TypeScript does not reject every questionable expression; string concatenation with numbers is allowed, and the protection comes when the resulting string meets code that expects a number.
  • Type errors do not stop output by default; use noEmitOnError or a tsc --noEmit step in CI to make them block a build.
  • Keep any for genuine unknowns, prefer unions and unknown for flexibility, and annotate function parameters and return types where data crosses boundaries.

The runtime result of JavaScript and TypeScript code is identical. What changes is when you find out something is wrong: in your editor, rather than in front of your users.