In today’s fast-paced digital landscape, JavaScript has become the go-to language for building dynamic web applications. However, JavaScript’s dynamic typing can sometimes lead to subtle errors, making it challenging to catch them early in the development process.

That’s where TypeScript comes in — to revolutionize the way we write JavaScript code.

In this article, we will take a deep dive into the world of TypeScript and explore its features, advantages, and best practices. You will also learn how TypeScript addresses the limitations of JavaScript and unlocks the power of static typing in building robust and scalable web applications.

Let’s dive in!

What Is TypeScript?

TypeScript is a superset of JavaScript that adds optional static typing and advanced features to JavaScript. It was developed by Microsoft and was initially released in October 2012. Since its release in 2012, it has rapidly gained widespread adoption in the web development community.

According to the 2022, Stack Overflow developer survey, TypeScript emerged as the 4th most loved technology with 73.46%. TypeScript was created to address some of the limitations of JavaScript, such as its lack of strong typing, which can lead to subtle errors that are difficult to catch during development.

For example, consider the following JavaScript code:

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

let result = add(10, "20"); // No error, but result is "1020" instead of 30

The code above creates a function add, which is dynamically typed. The type of the arguments a and b is not enforced. As a result, passing a string instead of a number as an argument doesn’t produce an error, but instead concatenates the values as strings, leading to unexpected behavior.

With TypeScript, optional static typing is introduced, allowing developers to specify the types of variables, function parameters, and return values, catching type-related errors during development.

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

let result = add(10, "20"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'

In the TypeScript code above, the types of parameters a and b are explicitly defined as numbers. If a string is passed as an argument, TypeScript will raise a compile-time error, providing early feedback to catch potential issues.

Features of TypeScript

TypeScript provides several powerful features for modern web development that address some of the limitations of JavaScript. These features offer enhanced developer experience and code organization. They include:

1. Static Typing

TypeScript has a strong typing system that allows for specifying the types of variables and function parameters at compile-time. This enables early detection of type-related errors, making the code more reliable and less prone to bugs.

In JavaScript, on the other hand, variables are dynamically typed, meaning their type can change during runtime.

For example, the JavaScript code below shows the declaration of two variables which are dynamically typed as number and string:

let num1 = 10; // num1 is dynamically typed as a number
let num2 = "20"; // num2 is dynamically typed as a string

let result = num1 + num2; // No error at compile-time
console.log(result); // Output: "1020"

This code above will output “1020”, a concatenation of number and string. This is not the expected output — meaning this can affect your code. The downside with JavaScript is that it will throw no error. You can fix this with TypeScript by specifying the types of each variable:

let num1: number = 10; // num1 is statically typed as a number
let num2: string = "20"; // num2 is statically typed as a string

let result = num1 + num2; // Error: Type 'string' is not assignable to type 'number'

In the code above, an attempt to concatenate a number and a string using the + operator results in a compile-time error, as TypeScript enforces strict type checking.

This helps catch potential type-related bugs before executing the code, leading to more robust and error-free code.

2. Optional Typing

TypeScript provides flexibility in choosing to use static typing or not. This means that you can choose to specify types for variables and function parameters or let TypeScript infer the types automatically based on the value assigned.

For example:

let num1: number = 10; // num1 is statically typed as a number
let num2 = "20"; // num2 is dynamically typed as a string

let result = num1 + num2; // Error: Operator '+' cannot be applied to types 'number' and 'string'

In this code, the type of num2 is inferred as string based on the value assigned, but you can choose to specify the type if desired.

You can also set the type to any, which means it accepts any type of value:

let num1: number = 10;
let num2: any = "20";

let result = num1 + num2; // Error: Operator '+' cannot be applied to types 'number' and 'string'

3. ES6+ Features

TypeScript supports modern JavaScript features, including those introduced in ECMAScript 6 (ES6) and later versions.

This allows developers to write cleaner and more expressive code using features such as arrow functions, destructuring, template literals, and more, with added type checking.

For example:

const greeting = (name: string): string => {
  return `Hello, ${name}!`; // Use of arrow function and template literal
};

console.log(greeting("John")); // Output: Hello, John!

In this code, the arrow function and template literal are used perfectly. The same applies to all JavaScript syntax.

4. Code Organization

In JavaScript, organizing code in separate files and managing dependencies can become challenging as the codebase grows. However, TypeScript provides built-in support for modules and namespaces to organize code better.

Modules allow for encapsulation of code within separate files, making it easier to manage and maintain large codebases.

Here’s an example:

// greeting.ts:
export function greet(name: string): string { // Export a function from a module
  return `Hello, ${name}!`;
}

// app.ts:
import { greet } from "./greeting"; // Import from a module

console.log(greet("John")); // Output: Hello, John!

In the above example, we have two separate files greeting.ts and app.ts. The app.ts file imports the greet function from the greeting.ts file using the import statement. The greeting.ts file exports the greet function using the export keyword, making it accessible for import in other files.

This allows for better code organization and separation of concerns, making managing and maintaining large codebases easier.

Namespaces in TypeScript provide a way to group related code together and avoid global namespace pollution. They can be used to define a container for a set of related classes, interfaces, functions, or variables.

Here’s an example:

namespace Utilities {
  export function greet(name: string): string {
    return `Hello, ${name}!`;
  }
  
  export function capitalize(str: string): string {
    return str.toUpperCase();
  }
}

console.log(Utilities.greet("John")); // Output: Hello, John!
console.log(Utilities.capitalize("hello")); // Output: HELLO

In this code, we define a namespace Utilities that contains two functions, greet and capitalize. We can access these functions using the namespace name followed by the function name, providing a logical grouping for related code.

5. Object-Oriented Programming (OOP) Features

TypeScript supports OOP concepts such as classes, interfaces, and inheritance, allowing for structured and organized code.

For example:

class Person {
  constructor(public name: string) {} // Define a class with a constructor
  greet(): string { // Define a method in a class
    return `Hello, my name is ${this.name}!`;
  }
}

const john = new Person("John"); // Create an instance of the class
console.log(john.greet()); // Output: Hello, my name is John!

6. Advanced Type System

TypeScript provides an advanced type system supporting generics, unions, intersections, and more. These features enhance the static type checking capabilities of TypeScript, allowing developers to write more robust and expressive code.

Generics: Generics allow for writing reusable code that can work with different types. Generics are like placeholders for types that are determined at runtime based on the values passed to a function or a class.

For example, let’s define a generic function identity that takes an argument value of type T and returns a value of the same type T:

function identity(value: T): T {
  return value;
}

let num: number = identity(10); // T is inferred as number
let str: string = identity("hello"); // T is inferred as string

In the above example, the type T is inferred based on the type of value passed to the function. In the first usage of the identity function, T is inferred as number because we pass 10 as the argument, and in the second usage, T is inferred as a string because we pass "hello" as the argument.

Unions and intersections: Unions and intersections are used to compose types and create more complex type relationships.

Unions allow for combining two or more types into a single type that can have any of the combined types. Intersections allow for combining two or more types into a single type that must satisfy all the combined types.

For example, we can define two types Employee and Manager, representing an employee and a manager, respectively.

type Employee = { name: string, role: string };
type Manager = { name: string, department: string };

Using the types Employee and Manager, we can define a union type EmployeeOrManager that can be either an Employee or a Manager.

type EmployeeOrManager = Employee | Manager; // Union type

let person1: EmployeeOrManager = { name: "John", role: "Developer" }; // Can be either Employee or Manager

In the code above, the person1 variable is of type EmployeeOrManager, which means it can be assigned an object that satisfies either Employee or Manager type.

We can also define an intersection type EmployeeOrManager that must satisfy both Employee and Manager types.

type EmployeeAndManager = Employee & Manager; // Intersection type

let person2: EmployeeAndManager = { name: "Jane", role: "Manager", department: "HR" }; // Must be both Employee and Manager

In the above code, the person2 variable is of type EmployeeAndManager, which means it must be an object that satisfies both Employee and Manager types.

7. Compatibility With JavaScript

TypeScript is designed to be a superset of JavaScript, which means that any valid JavaScript code is also valid TypeScript code. This makes integrating TypeScript into existing JavaScript projects easy without having to rewrite all the code.

TypeScript builds on top of JavaScript, adding optional static typing and additional features, but it still allows you to use plain JavaScript code as is.

For example, if you have an existing JavaScript file app.js, you can rename it to app.ts and start using TypeScript features gradually without changing the existing JavaScript code. TypeScript will still be able to understand and compile the JavaScript code as valid TypeScript.

Here’s an example of how TypeScript provides seamless integration with JavaScript:

// app.js - Existing JavaScript code
function greet(name) {
  return "Hello, " + name + "!";
}

console.log(greet("John")); // Output: Hello, John!

You can rename the above JavaScript file to app.ts and start using TypeScript features:

// app.ts - Same JavaScript code as TypeScript
function greet(name: string): string {
  return "Hello, " + name + "!";
}

console.log(greet("John")); // Output: Hello, John!

In the above example, we add a type annotation to the name parameter, specifying it as string, which is optional in TypeScript. The rest of the code remains the same as JavaScript. TypeScript is able to understand the JavaScript code and provide type checking for the added type annotation, making it easy to adopt TypeScript in an existing JavaScript project gradually.

Getting Started With TypeScript

TypeScript is an official compiler you can install into your project using npm. If you want to start using TypeScript 5.0 in your project, you can run the following command in your project’s directory:

npm install -D typescript

This will install the compiler in the node_modules directory, which you can now run with the npx tsc command.

For your JavaScript project, you will need to first initialize a node project using the following command to create a package.json file:

npm init -y

Then you can install the TypeScript dependency, create TypeScript files using the .ts extension and write your TypeScript code.

Once you have written your TypeScript code, you need to compile it to JavaScript using the TypeScript compiler (tsc). You can run the following command in your project directory:

npx tsc .ts

This compiles the TypeScript code in the specified file to JavaScript and generates a .js file with the same name.

You can then run the compiled JavaScript code in your project, just like you would run regular JavaScript code. You can use Node.js to execute the JavaScript code in a Node.js environment or include the compiled JavaScript file in an HTML file and run it in a browser.

Working With Interfaces

Interfaces in TypeScript are used to define contracts or shape of objects. They allow you to specify the structure or shape that an object should conform to.

Interfaces define a set of properties and/or methods that an object must have in order to be considered compatible with the interface. Interfaces can be used to provide type annotations for objects, function parameters, and return values, enabling better static type checking and code completion suggestions in IDEs.

Here’s an example of an interface in TypeScript:

interface Person {
  firstName: string;
  lastName: string;
  age: number;
}

In this example, we define an interface Person that specifies three properties: firstName of type string, lastName of type string, and age of type number.

Any object that has these three properties with the specified types will be considered compatible with the Person interface. Let’s now define objects that conform to the Person interface:

let person1: Person = {
  firstName: "John",
  lastName: "Doe",
  age: 30
};

let person2: Person = {
  firstName: "Jane",
  lastName: "Doe",
  age: 25
};

In this example, we create two objects person1 and person2 that conform to the Person interface. Both objects have the required properties firstName, lastName, and age with the specified types, so they are compatible with the Person interface.

Extending Interfaces

Interfaces can also be extended to create new interfaces that inherit properties from existing interfaces.

For example:

interface Animal {
  name: string;
  sound: string;
}

interface Dog extends Animal {
  breed: string;
}

let dog: Dog = {
  name: "Buddy",
  sound: "Woof",
  breed: "Labrador"
};

In this example, we define an interface Animal with properties name and sound, and then we define a new interface “Dog” that extends the Animal interface and adds a new propertybreed. The Dog interface inherits the properties from the Animal interface, so any object that conforms to the Dog interface must also have the properties name and sound.

Optional Properties

Interfaces can also have optional properties, which are denoted by a ? after the property name.

Here’s an example:

interface Car {
  make: string;
  model: string;
  year?: number;
}

let car1: Car = {
  make: "Toyota",
  model: "Camry"
};

let car2: Car = {
  make: "Honda",
  model: "Accord",
  year: 2020
};

In this example, we define an interface Car with properties make and model, and an optional property year. The year property is not required, so objects that conform to the Car interface can have it or not.

Advanced Type Checking

TypeScript also provides advanced options for type checking in tsconfig.json. These options can enhance the type checking capabilities of your TypeScript project and catch potential errors at compile-time, leading to more robust and reliable code.

1. strictNullChecks

When set to true, TypeScript enforces strict null checks, which means that variables cannot have a value of null or undefined unless explicitly specified with the union type of null or undefined.

For example:

{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

With this option enabled, TypeScript will catch potential null or undefined values at compile-time, helping to prevent runtime errors caused by accessing properties or methods on null or undefined variables.

// Example 1: Error - Object is possibly 'null'
let obj1: { prop: string } = null;
console.log(obj1.prop);

// Example 2: Error - Object is possibly 'undefined'
let obj2: { prop: string } = undefined;
console.log(obj2.prop);

2. strictFunctionTypes

When set to true, TypeScript enables strict checking of function types, including function parameter bivariance, which ensures that function arguments are strictly checked for type compatibility.

For example:

{
  "compilerOptions": {
    "strictFunctionTypes": true
  }
}

With this option enabled, TypeScript will catch potential function parameter type mismatches at compile-time, helping to prevent runtime errors caused by passing incorrect arguments to functions.

// Example: Error - Argument of type 'number' is not assignable to parameter of type 'string'
function greet(name: string) {
  console.log(`Hello, ${name}!`);
}

greet(123);

3. noImplicitThis

When set to true, TypeScript disallows the use of this with an implicit any type, which helps to catch potential errors when using this in class methods.

For example:

{
  "compilerOptions": {
    "noImplicitThis": true
  }
}

With this option enabled, TypeScript will catch potential errors caused by using this without proper type annotations or binding in class methods.

// Example: Error - The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'
class MyClass {
  private prop: string;

  constructor(prop: string) {
    this.prop = prop;
  }

  printProp() {
    console.log(this.prop);
  }
}

let obj = new MyClass("Hello");
setTimeout(obj.printProp, 1000); // 'this' context is lost, potential error

4. target

The target option specifies the ECMAScript target version for your TypeScript code. It determines the version of JavaScript that the TypeScript compiler should generate as output.

For example:

{
  "compilerOptions": {
    "target": "ES2018"
  }
}

With this option set to “ES2018“, TypeScript will generate JavaScript code that conforms to ECMAScript 2018 standard.

This can be useful if you want to take advantage of the latest JavaScript features and syntax, but also need to ensure backward compatibility with older JavaScript environments.

5. module

The module option specifies the module system to be used in your TypeScript code. Common options include “CommonJS“, “AMD“, “ES6“, “ES2015“, etc. This determines how your TypeScript modules are compiled into JavaScript modules.

For example:

{
  "compilerOptions": {
    "module": "ES6"
  }
}

With this option set to “ES6“, TypeScript will generate JavaScript code that uses ECMAScript 6 module syntax.

This can be useful if you are working with a modern JavaScript environment that supports ECMAScript 6 modules, such as in a front-end application using a module bundler like webpack or Rollup.

6. noUnusedLocals and noUnusedParameters

These options enable TypeScript to catch unused local variables and function parameters, respectively.

When set to true, TypeScript will emit compilation errors for any local variables or function parameters that are declared but not used in the code.

For example:

{
  "compilerOptions": {
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

These are just a few more examples of advanced type-checking options in TypeScript’s tsconfig.json file. You can check out the official documentation for more.

Best Practices and Tips for Using TypeScript

1. Properly Annotate Types for Variables, Function Parameters, and Return Values

One of the key benefits of TypeScript is its strong typing system, which allows you to explicitly specify the types of variables, function parameters, and return values.

This improves code readability, catches potential type-related errors early, and enables intelligent code completion in IDEs.

Here’s an example:

// Properly annotating variable types
let age: number = 25;
let name: string = "John";
let isStudent: boolean = false;
let scores: number[] = [98, 76, 89];
let person: { name: string, age: number } = { name: "John", age: 25 };

// Properly annotating function parameter and return types
function greet(name: string): string {
  return "Hello, " + name;
}

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

2. Utilizing TypeScript’s Advanced Type Features Effectively

TypeScript comes with a rich set of advanced type features, such as generics, unions, intersections, conditional types, and mapped types. These features can help you write more flexible and reusable code.

Here’s an example:

// Using generics to create a reusable function
function identity(value: T): T {
  return value;
}

let num: number = identity(42); // inferred type: number
let str: string = identity("hello"); // inferred type: string

// Using union types to allow multiple types
function display(value: number | string): void {
  console.log(value);
}

display(42); // valid
display("hello"); // valid
display(true); // error

3. Writing Maintainable and Scalable Code With TypeScript

TypeScript encourages writing maintainable and scalable code by providing features such as interfaces, classes, and modules.

Here’s an example:

// Using interfaces for defining contracts
interface Person {
  name: string;
  age: number;
}

function greet(person: Person): string {
  return "Hello, " + person.name;
}

let john: Person = { name: "John", age: 25 };
console.log(greet(john)); // "Hello, John"

// Using classes for encapsulation and abstraction
class Animal {
  constructor(private name: string, private species: string) {}

  public makeSound(): void {
    console.log("Animal is making a sound");
  }
}

class Dog extends Animal {
  constructor(name: string, breed: string) {
    super(name, "Dog");
    this.breed = breed;
  }

  public makeSound(): void {
    console.log("Dog is barking");
  }
}

let myDog: Dog = new Dog("Buddy", "Labrador");
myDog.makeSound(); // "Dog is barking"

4. Leveraging TypeScript’s Tooling and IDE Support

TypeScript has excellent tooling and IDE support, with features such as autocompletion, type inference, refactoring, and error checking.

Take advantage of these features to enhance your productivity and catch potential errors early in the development process. Make sure to use a TypeScript-aware IDE, such as Visual Studio Code, and install the TypeScript plugin for a better code editing experience.

VS Code TypeScript extension
VS Code TypeScript extension

Summary

TypeScript offers a wide range of powerful features that can greatly enhance your web development projects.

Its strong static typing, advanced type system, and object-oriented programming capabilities make it a valuable tool for writing maintainable, scalable, and robust code. TypeScript’s tooling and IDE support also provide a seamless development experience.

If you’d like to explore TypeScript and its capabilities, you can do that today thanks to Kinsta’s Application Hosting.