Home / Articles

TypeScript Essentials: A Hands-On Guide for JavaScript Developers

TypeScript for JavaScript developers, hands on: core types, tsconfig, strict mode and a step-by-step migration path that makes code safer to change later.

Publisher: REACTAPP.TOP18 min read4117 words

This article is published in English.

Sources: TypeScript for JavaScript Developers: A Practical Beginner’s Guide

If you already know JavaScript, picking up TypeScript feels less like learning a brand-new language and more like extending one you already understand.

TypeScript is essentially JavaScript enhanced with a type system layered on top.

You keep working with variables, functions, arrays, objects, classes, promises, async/await, modules, and virtually everything else that's already familiar. What TypeScript brings to the table is a set of tools that catch mistakes early, before your code ever runs in a browser or on a server.

This guide walks through the TypeScript concepts most relevant to developers who already write JavaScript.

What Is TypeScript?

TypeScript is a language layered on top of JavaScript.

In most cases, any JavaScript program you write is also valid TypeScript. What TypeScript contributes is static type checking, and it ultimately compiles down into plain JavaScript that runs in browsers and on Node.js.

Take this simple JavaScript snippet as an example:

function add(a, b) {
  return a + b;
}

Rewritten in TypeScript, the same function can declare what types of values it accepts:

function add(a: number, b: number): number {
  return a + b;
}

Breaking this down:

  • a: number requires a to be a number.
  • b: number requires b to be a number.
  • The trailing : number declares that the function's return value is a number.

With these annotations in place, TypeScript can flag an incorrect function call before execution even begins:

add(10, 20);      // Correct
add("10", "20");  // TypeScript error

In plain JavaScript, calling the function with string arguments would silently return "1020", since the plus operator would concatenate the strings instead of adding numbers. TypeScript catches this kind of unintended behavior ahead of time.

Why Use TypeScript?

JavaScript's flexibility is a strength, but it can also open the door to subtle bugs.

Take this function as an example:

function calculateDiscount(price, percentage) {
  return price - price * percentage / 100;
}

Calling it correctly works as expected:

calculateDiscount(1000, 10);

But nothing stops you from calling it like this instead:

calculateDiscount("1000", "ten");

The resulting problem only surfaces once the code actually executes.

With TypeScript, you can specify the types the function expects:

function calculateDiscount(
  price: number,
  percentage: number
): number {
  return price - price * percentage / 100;
}

Now TypeScript flags invalid arguments immediately, without needing to run the code first.

Some of the key advantages this brings include:

  • Catching errors while you're still writing code
  • Improved autocomplete support in your editor
  • Simpler, safer refactoring
  • More explicit function and object shapes
  • Smoother collaboration on larger codebases
  • Types that act as built-in documentation

TypeScript's value becomes especially clear once a project grows past a handful of files or involves more than one contributor.

Installing TypeScript

TypeScript can be installed globally with:

npm install -g typescript

To confirm the installed version, run:

tsc --version

Then create a file called:

app.ts

Add a bit of TypeScript code to it:

const message: string = "Hello TypeScript";
console.log(message);

Compile the file with:

tsc app.ts

This produces a compiled JavaScript output:

app.js

You can run the result directly with Node.js:

node app.js

In production setups, tools and frameworks like React, Next.js, Angular, and NestJS typically take care of this compilation step for you automatically.

The tsconfig.json File

Most TypeScript projects rely on a tsconfig.json file to configure the compiler.

You can generate one with:

tsc --init

A minimal configuration might look like this:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Here are a few of the more important settings:

target

This controls which version of JavaScript the compiler outputs.

"target": "ES2020"

module

This sets the module format used in the compiled output.

"module": "ESNext"

strict

Turning this on activates a more rigorous level of type checking.

"strict": true

For any new project, leaving strict mode turned on is strongly advised.

rootDir

This tells the compiler where your TypeScript source files live.

"rootDir": "./src"

outDir

This defines where the compiled JavaScript output should be written.

"outDir": "./dist"

Basic Types

TypeScript ships with several primitive types you'll already recognize.

String

let username: string = "Vijeet";

Number

let experience: number = 10;

TypeScript doesn't distinguish between integers and floating-point numbers; both fall under number.

let age: number = 35;
let price: number = 999.99;

Boolean

let isActive: boolean = true;

Null and Undefined

let emptyValue: null = null;
let missingValue: undefined = undefined;

Under strict mode, you'll need to handle null and undefined more deliberately.

Type Inference

You don't have to annotate every single value by hand.

TypeScript is often able to figure out a variable's type based on what's assigned to it.

let city = "Ahmedabad";

Here, TypeScript automatically recognizes city as a string.

Trying to reassign it like this will trigger an error:

city = 100;

The same logic applies here:

let total = 500;

TypeScript infers that total holds a number.

Relying on inference keeps your code less cluttered. Reserve explicit type annotations for places where they add real clarity, such as function parameters, API response shapes, and more complex object structures.

Arrays

Here's a plain JavaScript array:

const skills = ["JavaScript", "React", "Node.js"];

TypeScript lets you annotate the array's element type:

const skills: string[] = [
  "JavaScript",
  "React",
  "Node.js"
];

You can also write it with the generic syntax:

const skills: Array<string> = [
  "JavaScript",
  "React",
  "Node.js"
];

The same idea applies to numeric arrays:

const scores: number[] = [75, 82, 91];

TypeScript blocks any attempt to insert a value of the wrong type:

scores.push(95);       // Correct
scores.push("ninety"); // Error

Objects

You can give a plain JavaScript object an explicit shape.

const user: {
  name: string;
  age: number;
  isAdmin: boolean;
} = {
  name: "Vijeet",
  age: 35,
  isAdmin: true
};

If a property is missing or has the wrong type, TypeScript flags it as an error.

const user: {
  name: string;
  age: number;
} = {
  name: "Vijeet"
};

Here, the required age property is missing from the object.

When you need to reuse the same object shape in several places, interfaces or type aliases are a better fit than writing the structure inline each time.

Interfaces

An interface defines the shape an object is expected to have.

interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

Once defined, you can reuse that interface anywhere:

const user: User = {
  id: 1,
  name: "Vijeet",
  email: "vijeet@example.com",
  isActive: true
};

Interfaces come in handy in situations such as:

  • describing a user's stored details
  • modeling a catalog item
  • validating the fields entered in a form
  • shaping the payload returned from an API call
  • typing the props passed into a component
  • representing a row from a database table

For instance:

interface Product {
  id: number;
  title: string;
  price: number;
}

A function can then accept a Product as a parameter:

function displayProduct(product: Product): void {
  console.log(`${product.title}: ₹${product.price}`);
}

Optional Properties

Some object properties won't always be present.

You mark these by adding a question mark after the property name:

interface User {
  id: number;
  name: string;
  phone?: string;
}

Both of the following objects satisfy the interface:

const userOne: User = {
  id: 1,
  name: "Amit"
};
const userTwo: User = {
  id: 2,
  name: "Neha",
  phone: "9876543210"
};

Since phone is optional, its type becomes:

string | undefined

That means you should verify it exists before using it:

if (userTwo.phone) {
  console.log(userTwo.phone);
}

Type Aliases

A type alias lets you assign a reusable name to any type.

type UserId = number;

You can then use that alias as a type:

const id: UserId = 101;

Type aliases really shine when describing unions or more elaborate type combinations.

type Status = "pending" | "approved" | "rejected";

With this alias in place, only three specific strings are permitted:

let applicationStatus: Status = "pending";

Trying anything else raises an error:

applicationStatus = "completed";

Interfaces vs Type Aliases

Both interfaces and type aliases can model the structure of an object.

Here's the interface version:

interface Customer {
  name: string;
  email: string;
}

And the equivalent type alias version:

type Customer = {
  name: string;
  email: string;
};

As a rough rule of thumb for newcomers:

  • Reach for interface when describing objects or class shapes.
  • Reach for type when working with unions, aliases for primitives, tuples, or more intricate combined types.

This isn't a hard-and-fast rule, though, in plenty of cases either approach works equally well.

Union Types

A union type lets a value take on one of several possible types.

let userId: string | number;

Both of these assignments are legal:

userId = 101;
userId = "USR-101";

Union types are handy when data coming from the real world doesn't always follow one fixed format.

function printId(id: string | number): void {
  console.log(id);
}

Before calling a method that only exists on one of the possible types, you need to narrow the value down first:

function printId(id: string | number): void {
  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(0));
  }
}

This technique is known as type narrowing.

Literal Types

Literal types limit a variable to an exact, predefined set of values.

type Theme = "light" | "dark" | "system";

This assignment is fine:

let selectedTheme: Theme = "dark";

But this one is rejected:

selectedTheme = "blue";

Literal types are a good match for cases like:

  • a record's current status
  • the permissions tier assigned to a user
  • a visual theme setting
  • the visual style of a button
  • the HTTP verb used in a request
  • the mode an application is running in

As an example:

type UserRole = "admin" | "editor" | "customer";

Functions

For function parameters, the type goes right after the parameter's name.

function greet(name: string): string {
  return `Hello, ${name}`;
}

The function's return type goes after the closing parenthesis of the parameter list.

function multiply(a: number, b: number): number {
  return a * b;
}

When a function doesn't return a value, annotate it with void.

function logMessage(message: string): void {
  console.log(message);
}

Optional parameters are marked with ?:

function greetUser(
  name: string,
  title?: string
): string {
  if (title) {
    return `Hello, ${title} ${name}`;
  }
 return `Hello, ${name}`;
}

Default parameter values behave just like they do in plain JavaScript:

function calculateTax(
  amount: number,
  rate: number = 18
): number {
  return amount * rate / 100;
}

Typing Arrow Functions

You can annotate arrow functions the same way.

const subtract = (
  a: number,
  b: number
): number => {
  return a - b;
};

Here's a more compact form:

const subtract = (
  a: number,
  b: number
): number => a - b;

The any Type

Assigning the any type to a value tells TypeScript to stop checking it entirely.

let data: any = "Hello";
data = 100;
data = true;
data = { name: "Vijeet" };

While this might look convenient at first, relying on any too often throws away most of what TypeScript offers.

Take this example:

function processData(data: any): any {
  return data.someProperty.someMethod();
}

There's nothing TypeScript can do to catch mistakes in this function.

Reserve any for situations where it's genuinely unavoidable — for instance, while incrementally converting a JavaScript codebase, or when dealing with third-party data that lacks proper type definitions.

The unknown Type

unknown gives you a safer way to represent values whose type isn't known ahead of time.

let value: unknown;

Just like any, it accepts any kind of value:

value = "Hello";
value = 100;
value = true;

But unlike any, TypeScript forces you to narrow the type before you can actually use it:

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

That makes unknown a good fit for:

  • Data coming from external APIs
  • Values parsed from JSON
  • Error objects caught in a try/catch block
  • Input supplied by users
  • Anything whose shape isn't determined yet

Type Assertions

There are times when you have more information about a value than TypeScript can infer on its own.

A type assertion lets you tell the compiler how to treat that value.

const input = document.getElementById(
  "email"
) as HTMLInputElement;

Once asserted, you can access properties specific to that type:

console.log(input.value);

Keep in mind that an assertion doesn't change anything at runtime — it only changes how TypeScript interprets the value during compilation.

Because of this, you should use assertions sparingly and only when you're confident about the actual shape of the data.

Tuples

A tuple is an array with a set number of slots, where each position has its own defined type.

let userEntry: [number, string];
userEntry = [1, "Vijeet"];

Here, the first slot must hold a number and the second a string.

The following would fail:

userEntry = ["Vijeet", 1];

Tuples work well for small, fixed-shape data or paired values:

type Coordinate = [number, number];
const location: Coordinate = [23.0225, 72.5714];

When your data grows more complex or needs descriptive field names, plain objects tend to be clearer.

Enums

An enum lets you define a fixed group of named constants.

enum OrderStatus {
  Pending,
  Processing,
  Shipped,
  Delivered
}

You can reference them like this:

let status: OrderStatus = OrderStatus.Processing;

Giving enums string values often improves readability:

enum PaymentStatus {
  Pending = "pending",
  Paid = "paid",
  Failed = "failed"
}

That said, plenty of TypeScript users lean toward union types instead, especially for simpler scenarios:

type PaymentStatus =
  | "pending"
  | "paid"
  | "failed";

Union types typically compile to less output JavaScript and are a solid default for most front-end code.

Classes

TypeScript extends ordinary JavaScript classes with type checking.

class Employee {
  name: string;
  salary: number;
  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }  getAnnualSalary(): number {
    return this.salary * 12;
  }
}

Instantiating the class looks the same as in JavaScript:

const employee = new Employee("Rahul", 50000);
console.log(employee.getAnnualSalary());

Access modifiers are also part of TypeScript's class syntax.

class BankAccount {
  public accountHolder: string;
  private balance: number;
  constructor(accountHolder: string, balance: number) {
    this.accountHolder = accountHolder;
    this.balance = balance;
  }  public getBalance(): number {
    return this.balance;
  }
}

The main modifiers are:

  • public: visible from anywhere
  • private: visible only within the class itself
  • protected: visible within the class and any subclasses
  • readonly: can be set once but not reassigned afterward

Generics

Generics let you write flexible, reusable code that still keeps full type safety across different input types.

Take this function as a starting point:

function returnValue(value: any): any {
  return value;
}

Since it relies on any, TypeScript can't tell you anything about what comes out of it.

Rewriting it with a generic parameter fixes that:

function returnValue<T>(value: T): T {
  return value;
}

Now the return type matches whatever was passed in:

const text = returnValue<string>("Hello");
const count = returnValue<number>(100);

In many cases, TypeScript can infer the generic type automatically, so you don't have to spell it out:

const text = returnValue("Hello");
const count = returnValue(100);

Generics show up constantly when working with arrays, API response shapes, reusable UI components, custom hooks, and helper utilities.

function getFirstItem<T>(items: T[]): T | undefined {
  return items[0];
}

Used like this:

const firstNumber = getFirstItem([10, 20, 30]);
const firstName = getFirstItem(["Amit", "Neha"]);

Promises and Async Functions

Functions declared with async always resolve to promises.

async function getUsername(): Promise<string> {
  return "Vijeet";
}

An async function that resolves to a user object might look like this:

interface User {
  id: number;
  name: string;
}
async function getUser(): Promise<User> {
  return {
    id: 1,
    name: "Vijeet"
  };
}

And for a list of users, you'd wrap the return type in an array:

async function getUsers(): Promise<User[]> {
  return [
    { id: 1, name: "Vijeet" },
    { id: 2, name: "Rahul" }
  ];
}

Giving API results proper types this way protects the rest of your codebase from downstream mistakes.

Typing API Responses

Imagine an endpoint that returns product records:

interface Product {
  id: number;
  title: string;
  price: number;
}

You can wrap the fetch call in a function that declares its return type:

async function fetchProducts(): Promise<Product[]> {
  const response = await fetch("/api/products");
  if (!response.ok) {
    throw new Error("Unable to fetch products");
  }  const data: Product[] = await response.json();  return data;
}

There's a catch worth remembering: TypeScript has no way to verify this data once the app is actually running.

The line:

const data: Product[] = await response.json();

only instructs the compiler to treat the parsed response as a Product[] — it doesn't confirm that the server actually sent back objects shaped that way.

For applications where correctness really matters, developers commonly pair TypeScript with a runtime validation library like Zod to close this gap.

Handling DOM Elements

Browser element types come built into TypeScript.

const button = document.querySelector(
  "#submit-button"
);

The compiler knows that this query might come back empty, so the result type includes null alongside the element type.

That means you should guard against the missing case before using it:

if (button) {
  button.addEventListener("click", () => {
    console.log("Button clicked");
  });
}

If you need a more specific element type, you can assert it:

const emailInput = document.querySelector(
  "#email"
) as HTMLInputElement | null;

Then read its value once you've confirmed it exists:

if (emailInput) {
  console.log(emailInput.value);
}

TypeScript Does Not Replace Runtime Validation

This point deserves emphasis, especially for developers coming straight from JavaScript.

TypeScript's checks happen at development and compile time only. Once your code compiles down to JavaScript, all the type annotations disappear.

Take this function:

function double(value: number): number {
  return value * 2;
}

After compilation, it becomes roughly:

function double(value) {
  return value * 2;
}

At that point, there's no trace of the original type information left to enforce anything.

As a result, TypeScript cannot promise that:

  • An API actually returned well-formed data
  • A user typed in a valid value
  • A database record matches the expected shape
  • A third-party service honored its own documented contract

Anything coming from outside your code still needs to be checked while the program is running.

Migrating an Existing JavaScript Project

Converting a JavaScript codebase to TypeScript doesn't have to happen in one sweeping rewrite.

A step-by-step migration tends to work better in practice.

Step 1: Install TypeScript

npm install --save-dev typescript

Step 2: Create tsconfig.json

npx tsc --init

Step 3: Allow JavaScript Temporarily

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false
  }
}

Step 4: Rename Files Gradually

Change a file like:

utils.js

into:

utils.ts

And for React components, turn:

Button.jsx

into:

Button.tsx

Step 5: Fix Errors One File at a Time

It helps to begin with utility functions, shared data models, and smaller components, saving the more tangled application logic for later.

This incremental path is one of the biggest practical benefits TypeScript offers when adopting it into an existing project.

Common Mistakes JavaScript Developers Make

Using any Everywhere

const user: any = getUser();

This might silence an immediate compiler complaint, but it strips away all the type checking along with it.

A better approach is to define an actual interface:

interface User {
  id: number;
  name: string;
}

Adding Types to Every Variable

There's no need to write this:

const name: string = "Vijeet";
const count: number = 10;
const active: boolean = true;

because type inference already figures out these types on its own:

const name = "Vijeet";
const count = 10;
const active = true;

Reserve explicit annotations for places where they genuinely add clarity.

Ignoring null

This code can break at runtime:

const element = document.querySelector("#app");
element.innerHTML = "Hello";

The element being queried might simply not exist on the page.

Guard against that possibility instead:

const element = document.querySelector("#app");
if (element) {
  element.innerHTML = "Hello";
}

Using Type Assertions to Hide Problems

Writing something like this is risky:

const user = apiResponse as User;

The assertion doesn't check anything about the actual API response — it just tells the compiler to take your word for it.

Treating TypeScript as a Different Runtime

TypeScript is not executed by the browser directly. It gets compiled down to plain JavaScript before it ever runs.

This means everything you already know about JavaScript still applies.

A Practical Example

Consider a simple shopping cart, written the way you'd normally build it in JavaScript, but now with TypeScript types layered on top.

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

function calculateCartTotal(
  items: CartItem[]
): number {
  return items.reduce((total, item) => {
    return total + item.price * item.quantity;
  }, 0);
}
const cart: CartItem[] = [
  {
    id: 1,
    name: "Keyboard",
    price: 2500,
    quantity: 1
  },
  {
    id: 2,
    name: "Mouse",
    price: 1200,
    quantity: 2
  }
];
const total = calculateCartTotal(cart);
console.log(`Cart total: ₹${total}`);

With this in place, the function is now protected against malformed cart entries.

For instance, the following snippet will fail to compile:

const invalidCart: CartItem[] = [
  {
    id: 1,
    name: "Keyboard",
    price: "2500",
    quantity: 1
  }
];

The problem is that price is set to a string when it should be a number.

Should Every JavaScript Project Use TypeScript?

Not always.

Plain JavaScript still works fine for cases like:

  • Small experiments
  • Simple scripts
  • Fast prototypes
  • Basic landing pages
  • Throwaway internal tools

TypeScript tends to pay off more in situations such as:

  • Large frontend applications
  • React and Next.js codebases
  • Node.js backend services
  • Codebases shared across teams
  • Long-lived products
  • Projects dealing with complex data structures
  • Applications built and maintained by multiple developers

Generally speaking, the bigger and longer-lasting a project is, the more benefit you get from adopting TypeScript.

Final Thoughts

TypeScript isn't a substitute for JavaScript — it enhances the experience of writing JavaScript by wrapping your code in an extra layer of safety.

As someone coming from JavaScript, you already have most of the necessary background:

  • Variables
  • Functions
  • Objects
  • Arrays
  • Classes
  • Promises
  • Modules
  • Asynchronous code

What's left to learn is mainly how to express the structure of your data.

A good starting point includes:

  1. Primitive types
  2. Parameter and return types for functions
  3. Interfaces
  4. Union types
  5. Optional properties
  6. Generics
  7. Typed responses from APIs

Don't try to absorb every advanced feature right away. Start by annotating everyday JavaScript, and let the needs of your actual projects steer what you learn next.

Once TypeScript becomes second nature, going back to plain, untyped JavaScript can start to feel unexpectedly precarious.