TypeScript has surged in popularity in recent years, becoming a sought-after skill for developers. Many job descriptions now explicitly require or strongly prefer TypeScript proficiency.
If you’re already comfortable with JavaScript, transitioning to TypeScript will be a smooth process. The concepts are deeply related, and learning TypeScript will not only expand your skillset but also deepen your understanding of JavaScript itself, ultimately making you a more well-rounded developer.
In this guide, you will Learn Typescript by exploring:
- What TypeScript is and why it’s a valuable skill to acquire.
- How to set up a TypeScript project from scratch.
- Core TypeScript concepts, including types, interfaces, generics, and type casting.
- Practical application of TypeScript with React.
To further aid your learning journey, a handy TypeScript cheat sheet PDF and a poster are available. These resources condense the key takeaways of this article into a single page, perfect for quick reference and concept revision.
TypeScript cheat sheet PDF for quick reference and learning.
What Exactly is TypeScript?
TypeScript is best described as a superset of JavaScript. This means that any valid JavaScript code is also valid TypeScript code. TypeScript builds upon JavaScript by adding extra features, most notably static typing.
The primary motivation behind using TypeScript is to introduce static typing to JavaScript. Static typing enforces type constraints on variables during development. This early type checking can prevent a significant number of common runtime errors.
In contrast, JavaScript is a dynamically typed language. In JavaScript, variables can change their type during the execution of a program. Consider this example:
// JavaScript
let foo = "hello";
foo = 55; // foo's type changes from string to number - perfectly valid in JavaScript
// TypeScript
let bar: string = "hello";
bar = 55; // ERROR in TypeScript: Type 'number' is not assignable to type 'string'
Browsers cannot directly interpret TypeScript. Therefore, TypeScript code must be compiled into JavaScript using the TypeScript Compiler (TSC). We will delve into the compilation process shortly.
Is Learning TypeScript Worth Your Time?
The Benefits of TypeScript: Why Learn TypeScript?
- Enhanced Bug Detection: Research indicates that TypeScript can identify approximately 15% of common bugs during development. This early detection significantly reduces debugging time and improves code reliability.
- Improved Code Readability and Maintainability: Static typing makes code easier to understand. By explicitly defining types, you clarify the intended data structures and function signatures. This clarity is especially beneficial in team projects, where it helps developers understand each other’s code more effectively.
- Increased Job Opportunities: TypeScript’s growing popularity translates to a higher demand for TypeScript developers in the job market. Learning TypeScript opens doors to more and better job opportunities.
- Deeper JavaScript Understanding: Working with TypeScript provides a new perspective on JavaScript. You gain a more profound understanding of JavaScript’s underlying mechanisms and best practices.
Read this short article demonstrating how TypeScript can help prevent frustrating bugs.
Potential Drawbacks of TypeScript
- Increased Development Time (Initially): Writing TypeScript code can be slightly slower than JavaScript initially because you need to define types. For very small, personal projects, this might feel like unnecessary overhead.
- Compilation Step: TypeScript requires a compilation step to convert to JavaScript. This compilation process can add time to the development cycle, particularly in larger projects.
However, the initial investment in writing more explicit code and the compilation time are often outweighed by the significant reduction in bugs and the improved maintainability of TypeScript code.
For most projects, especially medium to large-scale applications, learning TypeScript and adopting it will ultimately save you considerable time and frustration in the long run.
If you already possess JavaScript knowledge, learning TypeScript is not a steep climb. It’s a valuable tool to add to your developer toolkit.
Setting Up Your First TypeScript Project: A Step-by-Step Guide
Prerequisites: Node.js and TypeScript Compiler Installation
First, ensure that you have Node.js installed on your system globally. Node.js is essential for running npm (Node Package Manager), which we’ll use to install the TypeScript compiler.
Next, install the TypeScript compiler globally on your machine using npm. Open your terminal or command prompt and run:
npm install -g typescript
To verify that the installation was successful, you can check the installed TypeScript compiler version:
tsc -v
This command should output the installed TypeScript version number.
Compiling TypeScript to JavaScript
Let’s compile a simple TypeScript file.
-
Create a TypeScript file: Open your preferred text editor and create a new file named
index.ts
. The.ts
extension signifies a TypeScript file. -
Write TypeScript code: Add the following JavaScript or TypeScript code to
index.ts
:
let sport = 'football';
let id = 5;
- Compile the TypeScript file: In your terminal, navigate to the directory containing
index.ts
and run the TypeScript compiler command:
tsc index.ts
The TypeScript compiler (TSC) will compile index.ts
into JavaScript and generate an index.js
file in the same directory. The contents of index.js
will be:
var sport = 'football';
var id = 5;
Customizing Compilation:
- Output file name: To specify a different name for the output JavaScript file, use the
--outfile
flag:
tsc index.ts --outfile custom-file-name.js
- Automatic compilation on changes (watch mode): For automatic recompilation whenever you save changes to your TypeScript file, use the
-w
or--watch
flag:
tsc index.ts -w
Important Note on TypeScript Compilation and Errors:
TypeScript is designed to report errors in your code editor as you type, providing immediate feedback. However, even if TypeScript reports errors, it will still compile your code into JavaScript.
For instance, consider this code snippet with a TypeScript error:
var sport = 'football';
var id = 5;
id = '5'; // TypeScript Error: Type 'string' is not assignable to type 'number'.
Although TypeScript highlights the error, running tsc index.ts
will still produce index.js
.
This behavior reflects TypeScript’s philosophy: it informs you of potential issues but ultimately trusts the developer’s judgment. It’s your responsibility to address TypeScript errors to ensure code quality.
Configuring TypeScript with tsconfig.json
The tsconfig.json
file resides at the root of your TypeScript project. It allows you to configure various TypeScript compiler options, including specifying root files, compiler behaviors, and the strictness of type checking.
- Initialize
tsconfig.json
: In your terminal, navigate to your project’s root directory and run:
tsc --init
This command generates a tsconfig.json
file with default settings in your project root.
- Understanding key
tsconfig.json
options:
Here are some commonly used options within compilerOptions
in tsconfig.json
:
{
"compilerOptions": {
/* Modules */
"target": "es2016", // Specifies the ECMAScript target version (e.g., ES2016, ES2020, ESNext).
"rootDir": "./src", // Specifies the root directory for input files.
"outDir": "./public", // Specifies the output directory for compiled JavaScript files. Often set to a folder served by a web server.
/* JavaScript Support */
"allowJs": true, // Allows JavaScript files to be part of your TypeScript project and compilation.
"checkJs": true, // Enables type checking for JavaScript files within your project.
/* Emit */
"sourceMap": true, // Generates source map files (.map) alongside JavaScript files. Source maps are crucial for debugging TypeScript code in browsers.
"removeComments": true, // Strips comments from the output JavaScript code.
},
"include": ["src"] // Specifies the files or directories to include in the compilation. Here, only files within the "src" directory will be compiled.
}
- Compiling with
tsconfig.json
:
Once tsconfig.json
is configured, you can simply run tsc
in your terminal from the project root. TypeScript will automatically use the settings defined in tsconfig.json
.
To compile and watch for changes using tsconfig.json
settings, use:
tsc -w
Important: When you explicitly specify input files on the command line (e.g., tsc index.ts
), tsconfig.json
settings are generally ignored. tsconfig.json
is most effective when you run tsc
without specifying input files, allowing it to manage your entire project based on the configuration file.
Deep Dive into Types in TypeScript
Primitive Types in TypeScript
In JavaScript, primitive values are fundamental data types that are not objects and lack methods. JavaScript has 7 primitive data types:
string
number
bigint
boolean
undefined
null
symbol
Primitive values are immutable; they cannot be changed directly. It’s important to distinguish between a primitive value itself and a variable that holds a primitive value. A variable can be reassigned to a new value, but the original primitive value remains unchanged. This is different from objects, arrays, and functions, which can be altered in place.
Example of immutability:
let name = 'Danny';
name.toLowerCase(); // String method does not modify the original string
console.log(name); // Output: Danny - the string remains unchanged
let arr = [1, 3, 5, 7];
arr.pop(); // Array method *mutates* the array
console.log(arr); // Output: [1, 3, 5] - the array is modified
name = 'Anna'; // Assignment creates a new primitive value for the variable
In JavaScript, primitive values (except null
and undefined
) have object wrapper equivalents: String
, Number
, BigInt
, Boolean
, and Symbol
. These wrapper objects provide methods for working with primitive values.
TypeScript and Primitive Types:
TypeScript allows you to explicitly define the type of a variable using type annotations (or type signatures). This is done by adding : type
after the variable declaration.
Examples of type annotations for primitive types:
let id: number = 5;
let firstname: string = 'danny';
let hasDog: boolean = true;
let unit: number; // Declare a variable without immediate assignment
unit = 5;
However, in many cases, explicitly stating the type is not necessary. TypeScript’s type inference can automatically determine the type of a variable based on its initial value:
let id = 5; // TypeScript infers 'id' to be of type 'number'
let firstname = 'danny'; // TypeScript infers 'firstname' to be 'string'
let hasDog = true; // TypeScript infers 'hasDog' to be 'boolean'
hasDog = 'yes'; // ERROR: Type '"yes"' is not assignable to type 'boolean'.
Union Types:
TypeScript also supports union types, which allow a variable to hold values of more than one type. You define a union type using the |
operator:
let age: string | number;
age = 26; // Valid: 'age' can be a number
age = '26'; // Valid: 'age' can also be a string
Reference Types in TypeScript
In JavaScript, “almost everything” is an object. Surprisingly, strings, numbers, and booleans can even be objects if created using the new
keyword:
let firstname = new String('Danny');
console.log(firstname); // Output: String {'Danny'} - an object wrapper around the primitive
However, when we talk about reference types in JavaScript, we typically refer to arrays, objects, and functions.
Primitive vs. Reference Types: A Key Distinction
Understanding the difference between primitive and reference types is crucial.
-
Primitive Types: When a primitive type is assigned to a variable, the variable directly contains the primitive value. Each primitive value resides in a unique memory location. Variables holding primitive data are independent.
Example:
let x = 2; let y = 1; x = y; y = 100; console.log(x); // Output: 1 (x remains 1, even though y changed to 100)
-
Reference Types: Reference types (objects, arrays, functions) do not directly store the value. Instead, they store a reference (an address) to a memory location where the actual object is stored. Multiple variables can point to the same memory location, meaning they refer to the same object.
Reference types memory locations
Example:
let point1 = { x: 1, y: 1 }; let point2 = point1; // point2 now references the same object as point1 point1.y = 100; // Modifying point1's y property console.log(point2.y); // Output: 100 (point2 reflects the change made to point1 because they point to the same object)
For a more in-depth explanation of primitive vs. reference types in JavaScript, refer to: Primitive vs reference types.
Arrays in TypeScript
TypeScript allows you to define the type of elements an array can hold:
let ids: number[] = [1, 2, 3, 4, 5]; // 'ids' can only contain numbers
let names: string[] = ['Danny', 'Anna', 'Bazza']; // 'names' can only contain strings
let options: boolean[] = [true, false, false]; // 'options' can only contain booleans
let books: object[] = [ // 'books' can only contain objects
{ name: 'Fooled by randomness', author: 'Nassim Taleb' },
{ name: 'Sapiens', author: 'Yuval Noah Harari' },
];
let arr: any[] = ['hello', 1, true]; // 'any[]' effectively disables type checking for the array
ids.push(6); // Valid
ids.push('7'); // ERROR: Argument of type 'string' is not assignable to parameter of type 'number'.
Arrays with Union Types:
You can create arrays that hold elements of multiple types using union types:
let person: (string | number | boolean)[] = ['Danny', 1, true];
person[0] = 100; // Valid: number is allowed
person[1] = { name: 'Danny' }; // Error - person array cannot contain objects
Type Inference for Arrays:
If you initialize an array with values, TypeScript can infer the array’s type, making explicit type annotations often unnecessary:
let person = ['Danny', 1, true]; // TypeScript infers type as (string | number | boolean)[]
person[0] = 100; // Valid
person[1] = { name: 'Danny' }; // Error - person array cannot contain objects
Tuples:
TypeScript introduces a special array type called tuples. A tuple is an array with a fixed size and known data types for each element at a specific index. Tuples are more restrictive than regular arrays.
let person: [string, number, boolean] = ['Danny', 1, true];
person[0] = 100; // Error - Value at index 0 can only be a string
Objects in TypeScript
Objects in TypeScript must adhere to defined property names and value types.
// Declare a variable 'person' with a specific object type annotation
let person: { name: string; location: string; isProgrammer: boolean; };
// Assign 'person' to an object with matching properties and types
person = { name: 'Danny', location: 'UK', isProgrammer: true };
person.isProgrammer = 'Yes'; // ERROR: Should be a boolean
person = { name: 'John', location: 'US' }; // ERROR: Missing the 'isProgrammer' property
Interfaces for Object Types:
For defining object structures, especially when you need to ensure multiple objects share the same structure, interfaces are commonly used. Interfaces provide a named way to define object types.
interface Person {
name: string;
location: string;
isProgrammer: boolean;
}
let person1: Person = { name: 'Danny', location: 'UK', isProgrammer: true };
let person2: Person = { name: 'Sarah', location: 'Germany', isProgrammer: false };
Function Properties in Interfaces:
Interfaces can also define function properties, specifying the function’s signature (parameters and return type). You can use both traditional JavaScript function syntax and ES6 arrow function syntax within interfaces:
interface Speech {
sayHi(name: string): string;
sayBye: (name: string) => string;
}
let sayStuff: Speech = {
sayHi: function(name: string) {
return `Hi ${name}`;
},
sayBye: (name: string) => `Bye ${name}`,
};
console.log(sayStuff.sayHi('Heisenberg')); // Output: Hi Heisenberg
console.log(sayStuff.sayBye('Heisenberg')); // Output: Bye Heisenberg
TypeScript doesn’t enforce a specific function syntax (arrow function or traditional function) for function properties in interfaces; both are acceptable as long as they match the defined signature.
Functions in TypeScript
TypeScript extends JavaScript functions by allowing you to define types for function parameters and the function’s return value.
// Define a function 'circle' that takes a 'diam' (diameter) of type 'number' and returns a 'string'
function circle(diam: number): string {
return 'The circumference is ' + Math.PI * diam;
}
console.log(circle(10)); // Output: The circumference is 31.41592653589793
The same function using ES6 arrow function syntax:
const circle = (diam: number): string => {
return 'The circumference is ' + Math.PI * diam;
};
console.log(circle(10)); // Output: The circumference is 31.41592653589793
Type Inference for Function Return Types:
TypeScript often infers the return type of a function automatically. In many cases, you don’t need to explicitly state the return type. However, for complex functions, explicitly defining the return type can improve code clarity.
// Explicitly stating return type
const circle: Function = (diam: number): string => {
return 'The circumference is ' + Math.PI * diam;
};
// Inferred return type - TypeScript automatically knows it's a string
const circle = (diam: number) => {
return 'The circumference is ' + Math.PI * diam;
};
Optional Parameters and Union Types in Function Parameters:
You can make function parameters optional by adding a question mark ?
after the parameter name. You can also use union types to allow parameters to accept multiple types.
const add = (a: number, b: number, c?: number | string) => {
console.log(c); // 'c' can be a number, string, or undefined
return a + b;
};
console.log(add(5, 4, 'I could pass a number, string, or nothing here!'));
// Output: I could pass a number, string, or nothing here!
// Output: 9
void
Return Type:
A function that doesn’t return any value is said to have a void
return type. You can explicitly specify void
as the return type, although TypeScript often infers it.
const logMessage = (msg: string): void => {
console.log('This is the message: ' + msg);
};
logMessage('TypeScript is superb'); // Output: This is the message: TypeScript is superb
Function Signatures for Function Variables:
To declare a function variable without immediately defining its implementation, you can use a function signature. This specifies the expected parameter types and return type of the function.
// Declare 'sayHello' variable with a function signature: takes string, returns void
let sayHello: (name: string) => void;
// Define the function, matching the signature
sayHello = (name) => {
console.log('Hello ' + name);
};
sayHello('Danny'); // Output: Hello Danny
Dynamic (Any) Types: Flexibility and Trade-offs
The any
type in TypeScript provides a way to opt out of type checking. Variables declared with any
can hold values of any type, effectively reverting TypeScript to behave like JavaScript in those specific cases.
let age: any = '100';
age = 100;
age = { years: 100, months: 2 }; // All valid with 'any' type
Caution with any
:
While any
offers flexibility, it’s generally recommended to minimize its use. Overusing any
defeats the purpose of TypeScript’s static typing and can lead to runtime errors that TypeScript is designed to prevent. It should be used sparingly when you truly need to work with values of unknown types or when migrating JavaScript code to TypeScript incrementally.
Type Aliases: Reusing Type Definitions
Type aliases allow you to create custom names for types. This can significantly improve code readability and reduce redundancy, especially when dealing with complex types. Type aliases promote the DRY (Don’t Repeat Yourself) principle.
type StringOrNumber = string | number;
type PersonObject = { name: string; id: StringOrNumber; };
const person1: PersonObject = { name: 'John', id: 1 };
const person2: PersonObject = { name: 'Delia', id: 2 };
const sayHello = (person: PersonObject) => {
return 'Hi ' + person.name;
};
const sayGoodbye = (person: PersonObject) => {
return 'Seeya ' + person.name;
};
In this example, StringOrNumber
and PersonObject
are type aliases. They make the code cleaner and easier to understand, especially if these type definitions are used in multiple places.
The DOM and Type Casting in TypeScript
TypeScript doesn’t inherently have runtime access to the Document Object Model (DOM) like JavaScript running in a browser. When you interact with DOM elements in TypeScript, the compiler cannot be certain that these elements actually exist at runtime.
Consider this example:
const link = document.querySelector('a');
console.log(link.href); // ERROR: Object is possibly 'null'. TypeScript can't guarantee the anchor tag exists.
TypeScript flags a potential error because document.querySelector('a')
might return null
if no anchor tag is found.
Non-Null Assertion Operator (!
):
The non-null assertion operator (!
) is a way to tell TypeScript that you are certain an expression will not be null
or undefined
. Use it with caution, as it bypasses TypeScript’s null-checking.
// Telling TypeScript: "I'm sure this anchor tag exists"
const link = document.querySelector('a')!;
console.log(link.href); // Assumes 'link' is not null and accesses 'href'
Type Inference with DOM Elements:
In some cases, TypeScript can infer the specific type of a DOM element. In the above example, TypeScript infers that link
is likely of type HTMLAnchorElement
(if it’s not null).
Type Casting for Specific DOM Element Types:
When selecting DOM elements using methods like getElementById
or querySelector
with class or ID selectors, TypeScript might not be able to infer the precise type of element. It might only know it’s a generic HTMLElement
.
const form = document.getElementById('signup-form');
console.log(form.method); // ERROR: Object is possibly 'null'.
// ERROR: Property 'method' does not exist on type 'HTMLElement'.
To resolve this, you can use type casting (or type assertion) to tell TypeScript the specific type of DOM element you expect. The as
keyword is used for type casting.
const form = document.getElementById('signup-form') as HTMLFormElement;
console.log(form.method); // Now TypeScript knows 'form' is an HTMLFormElement and has 'method'
TypeScript’s Event Object and Type Safety:
TypeScript has built-in type definitions for DOM events. When you work with event listeners, TypeScript provides type checking for event objects, helping you catch errors.
const form = document.getElementById('signup-form') as HTMLFormElement;
form.addEventListener('submit', (e: Event) => {
e.preventDefault(); // Prevents default form submission
console.log(e.tarrget); // ERROR: Property 'tarrget' does not exist on type 'Event'. Did you mean 'target'?
});
TypeScript’s type system catches even minor typos in event property names, improving code correctness.
Classes in TypeScript: Type Safety in Object-Oriented Programming
TypeScript enhances JavaScript classes by adding type annotations for class properties and methods, access modifiers, and more, bringing static typing to object-oriented programming.
class Person {
name: string;
isCool: boolean;
pets: number;
constructor(n: string, c: boolean, p: number) {
this.name = n;
this.isCool = c;
this.pets = p;
}
sayHello() {
return `Hi, my name is ${this.name} and I have ${this.pets} pets`;
}
}
const person1 = new Person('Danny', false, 1);
const person2 = new Person('Sarah', 'yes', 6); // ERROR: Argument of type 'string' is not assignable to parameter of type 'boolean'.
console.log(person1.sayHello()); // Output: Hi, my name is Danny and I have 1 pets
Type Enforcement in Classes:
TypeScript enforces the types defined for class properties in the constructor and throughout the class methods.
Arrays of Class Instances:
You can create arrays that specifically hold objects of a particular class:
let People: Person[] = [person1, person2]; // 'People' array can only contain 'Person' objects
Access Modifiers and readonly
:
TypeScript introduces access modifiers (public
, private
, protected
) and the readonly
modifier to control the accessibility and mutability of class properties.
class Person {
readonly name: string; // Immutable after initialization
private isCool: boolean; // Accessible only within the class
protected email: string; // Accessible within the class and subclasses
public pets: number; // Accessible from anywhere
constructor(n: string, c: boolean, e: string, p: number) {
this.name = n;
this.isCool = c;
this.email = e;
this.pets = p;
}
sayMyName() {
console.log(`Your not Heisenberg, you're ${this.name}`);
}
}
const person1 = new Person('Danny', false, '[email protected]', 1);
console.log(person1.name); // Valid: public access
person1.name = 'James'; // Error: read-only property
console.log(person1.isCool); // Error: private property - inaccessible outside the class
console.log(person1.email); // Error: protected property - inaccessible directly
console.log(person1.pets); // Valid: public property
Concise Class Property Definition in Constructor:
TypeScript allows a more concise syntax for defining and initializing class properties directly within the constructor parameters using access modifiers:
class Person {
constructor(
readonly name: string,
private isCool: boolean,
protected email: string,
public pets: number
) {}
sayMyName() {
console.log(`Your not Heisenberg, you're ${this.name}`);
}
}
const person1 = new Person('Danny', false, '[email protected]', 1);
console.log(person1.name); // Output: Danny
This syntax automatically declares and assigns the properties within the constructor, reducing boilerplate code. If no access modifier is specified, properties are public
by default.
Class Inheritance:
TypeScript classes support inheritance, similar to JavaScript. You can extend classes to create subclasses.
class Programmer extends Person {
programmingLanguages: string[];
constructor(
name: string,
isCool: boolean,
email: string,
pets: number,
pL: string[]
) {
super(name, isCool, email, pets); // Call to superclass constructor
this.programmingLanguages = pL;
}
}
For more in-depth information on classes, refer to the official TypeScript documentation.
Modules in TypeScript: Organizing Code
In JavaScript, modules are files containing related code. Modules promote code organization and reusability by allowing you to import and export functionality between files. TypeScript fully supports JavaScript modules.
When TypeScript code is compiled, it can be structured into multiple JavaScript files, mirroring the module structure of your TypeScript code.
Configuring tsconfig.json
for Modules:
To enable modern JavaScript module syntax (ES modules) in TypeScript, adjust the following options in your tsconfig.json
file:
{
"compilerOptions": {
"target": "es2016", // Or a later ES version
"module": "es2015" // Or "esnext" for latest module features
}
}
For Node.js projects, you might often use "module": "CommonJS"
as Node.js historically used CommonJS modules, although ES module support is increasingly available in Node.js as well.
HTML Script Tag for Modules:
When using ES modules in a browser environment, ensure your HTML script tag includes type="module"
:
<script type="module" src="/public/script.js"></script>
Importing and Exporting Modules:
TypeScript uses the standard ES6 import
and export
syntax for modules:
// src/hello.ts
export function sayHi() {
console.log('Hello there!');
}
// src/script.ts
import { sayHi } from './hello.js'; // Note: import from './hello.js'
sayHi(); // Output: Hello there!
Important: File Extensions in Imports:
Even within TypeScript files, when importing modules, use the .js
file extension in your import paths (e.g., ./hello.js
). TypeScript will handle the module resolution correctly during compilation.
Interfaces in TypeScript: Defining Contracts
Interfaces in TypeScript define the structure of objects. They act as contracts, specifying the properties and methods an object should have.
interface Person {
name: string;
age: number;
}
function sayHi(person: Person) {
console.log(`Hi ${person.name}`);
}
sayHi({ name: 'John', age: 48 }); // Valid: object matches Person interface
Type Aliases vs. Interfaces for Object Types:
You can also define object types using type aliases:
type Person = { name: string; age: number; };
function sayHi(person: Person) {
console.log(`Hi ${person.name}`);
}
sayHi({ name: 'John', age: 48 }); // Works the same as with interface
Or even define object types anonymously directly in function parameters:
function sayHi(person: { name: string; age: number }) {
console.log(`Hi ${person.name}`);
}
sayHi({ name: 'John', age: 48 }); // Anonymous object type definition
Key Difference: Extensibility of Interfaces vs. Type Aliases:
While interfaces and type aliases can often be used interchangeably for defining object structures, a crucial distinction is that interfaces are extendable (they can be reopened and properties added), while type aliases are not.
Interface Extension:
interface Animal { name: string }
interface Bear extends Animal { honey: boolean } // 'Bear' extends 'Animal'
const bear: Bear = { name: "Winnie", honey: true }
Type Alias Intersection for Extension-like Behavior:
Type aliases can achieve a similar effect to extension using intersections (&
):
type Animal = { name: string }
type Bear = Animal & { honey: boolean } // 'Bear' is an intersection of 'Animal' and { honey: boolean }
const bear: Bear = { name: "Winnie", honey: true }
Interface Merging (Reopening):
Interfaces can be declared multiple times, and TypeScript will merge their declarations. This is known as interface merging or reopening.
interface Animal { name: string }
// Re-open and add to the 'Animal' interface
interface Animal { tail: boolean }
const dog: Animal = { name: "Bruce", tail: true } // 'Animal' now has both 'name' and 'tail'
Type Aliases Cannot Be Reopened:
Type aliases, in contrast, cannot be redeclared or reopened after their initial definition.
type Animal = { name: string }
type Animal = { tail: boolean } // ERROR: Duplicate identifier 'Animal'.
Recommendation: Interfaces for Object Types (Generally):
The TypeScript documentation generally recommends using interfaces to define object types unless you specifically need features that type aliases offer (like union types or tuple types, or when you need to describe function types more complex than those readily expressible in interfaces).
Interfaces for Function Signatures:
Interfaces can also define function signatures, specifying the structure of functions as properties of objects:
interface Person {
name: string;
age: number;
speak(sentence: string): void; // Function signature within interface
}
const person1: Person = {
name: "John",
age: 48,
speak: (sentence) => console.log(sentence),
};
Interfaces vs. Classes: Type Checking vs. Object Factories:
You might wonder when to use interfaces versus classes.
-
Interfaces are purely for type checking at compile time. They do not exist in the compiled JavaScript code. Interfaces are about defining the shape of data.
-
Classes are JavaScript constructs that exist at runtime. They are used to create objects (instances) and can contain implementation details (methods and properties with logic). Classes are object factories.
Advantages of Interfaces:
- No JavaScript Overhead: Interfaces are erased during compilation, so they don’t add any bloat to your JavaScript output.
- Type Contracts: Interfaces primarily serve as type contracts, ensuring that objects conform to a specific structure during development.
Classes, on the other hand, are essential for object creation, inheritance, and encapsulating behavior in object-oriented programming.
Interfaces with Classes: Implementing Contracts
Classes can explicitly state that they implement an interface using the implements
keyword. This enforces that the class must have all the properties and methods defined in the interface.
interface HasFormatter {
format(): string;
}
class Person implements HasFormatter {
constructor(public username: string, protected password: string) {}
format(): string {
return this.username.toLocaleLowerCase();
}
}
// Variables of type 'HasFormatter' can hold objects that implement the interface
let person1: HasFormatter;
let person2: HasFormatter;
person1 = new Person('Danny', 'password123');
person2 = new Person('Jane', 'TypeScripter1990');
console.log(person1.format()); // Output: danny
Arrays of Interface-Implementing Objects:
You can create arrays that are type-safe to hold only objects that implement a specific interface:
let people: HasFormatter[] = []; // 'people' can only hold objects implementing 'HasFormatter'
people.push(person1);
people.push(person2);
This ensures that every object in the people
array is guaranteed to have the format()
method, as defined by the HasFormatter
interface.
Literal Types in TypeScript: Enforcing Specific Values
Literal types allow you to specify that a variable must hold one of a set of specific literal values (strings, numbers, booleans, or enums).
// 'favouriteColor' can only be one of these literal string values
let favouriteColor: 'red' | 'blue' | 'green' | 'yellow';
favouriteColor = 'blue'; // Valid
favouriteColor = 'crimson'; // ERROR: Type '"crimson"' is not assignable to type '"red" | "blue" | "green" | "yellow"'.
Literal types are useful for creating more constrained and self-documenting types, especially when you want to restrict a variable to a predefined set of allowed values.
Generics in TypeScript: Creating Reusable Components
Generics are a powerful feature in TypeScript that enable you to create components (functions, classes, interfaces) that can work with a variety of types while maintaining type safety. Generics enhance code reusability.
Motivation for Generics: Type Safety with Flexibility
Let’s illustrate the need for generics with an example. Suppose you want to create a function addID
that takes an object and adds a unique id
property to it.
const addID = (obj: object) => {
let id = Math.floor(Math.random() * 1000);
return { ...obj, id };
};
let person1 = addID({ name: 'John', age: 40 });
console.log(person1.id); // Works
console.log(person1.name); // ERROR: Property 'name' does not exist on type '{ id: number; }'.
TypeScript gives an error when you try to access person1.name
. This is because the type of obj
in addID
is just object
, which is too generic. TypeScript doesn’t know the specific properties of the object passed to addID
.
Introducing Generics: Type Parameters
Generics solve this by introducing type parameters. A type parameter is a placeholder for a type that will be specified later when the generic component is used. Conventionally, single uppercase letters like T
are used for type parameters.
// <T> is the type parameter. 'T' represents the type of the object passed in.
const addID = <T>(obj: T) => {
let id = Math.floor(Math.random() * 1000);
return { ...obj, id };
};
Now, when you call addID
with an object, TypeScript “captures” the type of that object and uses it for T
. addID
now knows the properties of the input object.
let person1 = addID({ name: 'John', age: 40 });
console.log(person1.id); // Works
console.log(person1.name); // Now works! TypeScript knows 'person1' has a 'name' property.
Constraints on Generics: Limiting Accepted Types
Currently, addID
accepts anything as input, even primitives like strings, which might not be desirable:
let person2 = addID('Sally'); // No TypeScript error, but probably not intended
console.log(person2.id);
console.log(person2.name); // ERROR: Property 'name' does not exist on type '"Sally" & { id: number; }'.
You can add constraints to generic type parameters to restrict the types that can be used. For example, you can constrain T
to be an object
:
// <T extends object> - 'T' must be an object or a subtype of object.
const addID = <T extends object>(obj: T) => {
let id = Math.floor(Math.random() * 1000);
return { ...obj, id };
};
let person2 = addID('Sally'); // ERROR: Argument of type 'string' is not assignable to parameter of type 'object'.
Now, addID
will only accept objects, catching errors earlier. However, arrays are also objects in JavaScript, so you might still want more specific constraints.
More Specific Constraints: Requiring Properties
You can constrain generics to require specific properties. For example, to ensure the object has a name
property of type string
:
// <T extends { name: string }> - 'T' must be an object with a 'name' property of type string.
const addID = <T extends { name: string }>(obj: T) => {
let id = Math.floor(Math.random() * 1000);
return { ...obj, id };
};
let person2 = addID(['Sally', 26]); // ERROR: Argument doesn't have a 'name' property.
Explicitly Specifying Type Arguments:
You can also explicitly specify the type argument for a generic function when calling it, although TypeScript often infers it automatically:
// Explicitly specify the type argument as { name: string; age: number }
let person1 = addID<{ name: string; age: number }>({ name: 'John', age: 40 });
Generics and Type Correspondence:
Generics are most powerful when you want to express a correspondence between input and output types. In addID
, the return type is related to the input type – it’s the input object with an added id
property. Generics allow you to capture and maintain this relationship in a type-safe way.
Generics vs. any
:
When you need a function to work with multiple types, generics are generally preferred over any
. Using any
sacrifices type safety. Consider this example:
function logLength(a: any) {
console.log(a.length); // No TypeScript error, but could cause runtime errors
return a;
}
let hello = 'Hello world';
logLength(hello); // Works
let howMany = 8;
logLength(howMany); // Runtime error: 'length' property of undefined (no TypeScript error!)
Using any
bypasses type checking, leading to potential runtime errors. Generics can provide type safety while still allowing flexibility.
Generic Constraint for length
Property:
interface hasLength { length: number; } // Interface defining a 'length' property
function logLength<T extends hasLength>(a: T) {
console.log(a.length); // Now safe to access 'length'
return a;
}
let hello = 'Hello world';
logLength(hello); // Works
let howMany = 8;
logLength(howMany); // ERROR: Argument of type 'number' does not satisfy the constraint 'hasLength'.
By using a generic constraint (<T extends hasLength>
), TypeScript ensures that only types with a length
property are accepted, catching potential errors at compile time.
Generics with Arrays:
You can create generic functions that work with arrays of elements that satisfy a certain constraint:
interface hasLength { length: number; }
function logLengths<T extends hasLength>(a: T[]) {
a.forEach(element => {
console.log(element.length);
});
}
let arr = [
'This string has a length prop',
['This', 'arr', 'has', 'length'],
{ material: 'plastic', length: 30 },
];
logLengths(arr); // Works for strings, arrays, and objects with 'length'
Generics are a fundamental and versatile feature in TypeScript for writing reusable and type-safe code.
Generics with Interfaces: Parameterizing Interface Types
Generics can also be used with interfaces to create parameterized interfaces. This allows you to define interfaces where the type of certain properties is not fixed but is determined when the interface is used.
// Interface with a type parameter <T> for the 'documents' property
interface Person<T> {
name: string;
age: number;
documents: T; // Type of 'documents' is generic
}
// 'documents' is specified as string[] for person1
const person1: Person<string[]> = {
name: 'John',
age: 48,
documents: ['passport', 'bank statement', 'visa'],
};
// 'documents' is specified as string for person2
const person2: Person<string> = {
name: 'Delia',
age: 46,
documents: 'passport, P45',
};
Here, the Person
interface is generic in terms of the documents
property’s type. When you use Person
, you specify the actual type for T
, making the interface adaptable to different data types.
Enums in TypeScript: Named Constants
Enums (enumerations) are a TypeScript-specific feature that allows you to define a set of named constants. Enums make code more readable and maintainable by replacing magic numbers or strings with descriptive names.
enum ResourceType {
BOOK,
AUTHOR,
FILM,
DIRECTOR,
PERSON,
}
console.log(ResourceType.BOOK); // Output: 0 (enums are number-based by default)
console.log(ResourceType.AUTHOR); // Output: 1
// Starting enums from a specific number
enum ResourceType {
BOOK = 1,
AUTHOR,
FILM,
DIRECTOR,
PERSON,
}
console.log(ResourceType.BOOK); // Output: 1
console.log(ResourceType.AUTHOR); // Output: 2
By default, enums are number-based, assigning numerical values starting from 0 to each enum member. You can also explicitly assign number values or start from a different number.
String-Based Enums:
Enums can also be string-based, where each enum member is associated with a string literal value.
enum Direction {
Up = 'Up',
Right = 'Right',
Down = 'Down',
Left = 'Left',
}
console.log(Direction.Right); // Output: Right
console.log(Direction.Down); // Output: Down
Benefits of Enums:
- Readability: Enums replace less descriptive numbers or strings with meaningful names, making code easier to understand.
- Maintainability: If the values of constants need to change, you only need to modify the enum definition, rather than searching and replacing magic values throughout your code.
- Type Safety: Enums provide type safety by restricting variables to a predefined set of valid values.
- IntelliSense and Autocompletion: Code editors often provide autocompletion suggestions for enum members, reducing typos and improving development speed.
Enums are particularly useful when you have a fixed set of related options or categories in your application.
TypeScript Strict Mode: Enhancing Type Checking
Enabling strict mode in your tsconfig.json
file is highly recommended for TypeScript projects. Strict mode turns on a set of stricter type-checking rules that help catch more potential errors during development.
{
// tsconfig.json
"strict": true // Enables all strict type-checking options
}
Let’s look at two key features enabled by strict mode: noImplicitAny
and strictNullChecks
.
noImplicitAny
: Explicit Type Annotations
With noImplicitAny
enabled, TypeScript will raise an error whenever it cannot infer the type of a variable and the type would otherwise default to any
. This forces you to be more explicit about types, reducing the risk of unintended any
types creeping into your code.
Without noImplicitAny
:
function logName(a) { // Parameter 'a' implicitly has 'any' type (no error without strict mode)
console.log(a.name);
}
logName(97); // No TypeScript error (but will cause runtime error)
Without strict mode, TypeScript implicitly infers a
to be of type any
, and no error is reported even though accessing a.name
on a number will lead to a runtime error.
With noImplicitAny
:
function logName(a) { // ERROR: Parameter 'a' implicitly has an 'any' type. (error with strict mode)
console.log(a.name);
}
With noImplicitAny
, TypeScript immediately flags an error, prompting you to explicitly define the type of a
.
strictNullChecks
: Explicit Null and Undefined Handling
When strictNullChecks
is disabled (the default in non-strict mode), TypeScript essentially treats null
and undefined
as part of every type. This can hide potential null/undefined errors and lead to unexpected runtime behavior.
With strictNullChecks
enabled, null
and undefined
become distinct types. You’ll get type errors if you try to use a variable that might be null
or undefined
as if it were guaranteed to have a concrete value (e.g., a string or an object).
Without strictNullChecks
:
const getSong = () => {
return 'song';
};
let whoSangThis: string = getSong();
const singles = [
{ song: 'touch of grey', artist: 'grateful dead' },
{ song: 'paint it black', artist: 'rolling stones' },
];
const single = singles.find((s) => s.song === whoSangThis); // 'single' might be undefined if not found
console.log(single.artist); // No TypeScript error (but potential runtime error if 'single' is undefined)
Without strictNullChecks
, TypeScript doesn’t complain about single.artist
even though singles.find
could return undefined
.
With strictNullChecks
:
const getSong = () => {
return 'song';
};
let whoSangThis: string = getSong();
const singles = [
{ song: 'touch of grey', artist: 'grateful dead' },
{ song: 'paint it black', artist: 'rolling stones' },
];
const single = singles.find((s) => s.song === whoSangThis);
console.log(single.artist); // ERROR: Object is possibly 'undefined'. (error with strict mode)
With strictNullChecks
, TypeScript correctly identifies that single
might be undefined
and raises an error, forcing you to handle the potential null/undefined case explicitly.
Handling Null/Undefined with strictNullChecks
:
You need to check if single
exists before accessing single.artist
:
if (single) {
console.log(single.artist); // Safe to access 'single.artist' within the 'if' block
}
Strict mode, especially noImplicitAny
and strictNullChecks
, promotes more robust and error-resistant TypeScript code.
Narrowing in TypeScript: Refining Types Based on Conditions
Type narrowing is a process in TypeScript where the type of a variable becomes more specific within a certain code block based on conditions or type guards. TypeScript intelligently analyzes code flow to refine types.
Type Narrowing with typeof
:
A common way to trigger type narrowing is using typeof
checks. TypeScript understands typeof
and can narrow union types accordingly.
function addAnother(val: string | number) {
if (typeof val === 'string') {
// Inside this 'if' block, TypeScript narrows 'val' to type 'string'
return val.concat(' ' + val); // String methods are now safely available
}
// Outside the 'if' block (in the 'else' branch), TypeScript knows 'val' must be 'number'
return val + val; // Number operations are now safely available
}
console.log(addAnother('Woooo')); // Output: Woooo Woooo
console.log(addAnother(20)); // Output: 40
Type Narrowing with Discriminated Unions:
Discriminated unions are a pattern where you have a union type, and each type in the union has a common, literal type property (a discriminant or tag) that helps distinguish between the types at runtime.
interface Vehicle { topSpeed: number; }
interface Train extends Vehicle { type: 'Train'; carriages: number; } // 'type' is the discriminant
interface Plane extends Vehicle { type: 'Plane'; wingSpan: number; } // 'type' is the discriminant
type PlaneOrTrain = Plane | Train;
function getSpeedRatio(v: PlaneOrTrain) {
// Initially, TypeScript only knows 'v' is 'PlaneOrTrain'
console.log(v.carriages); // ERROR: 'carriages' does not exist on type 'Plane'.
}
To enable type narrowing with discriminated unions, add a common discriminant property to each interface with a unique literal type value.
interface Vehicle { topSpeed: number; }
interface Train extends Vehicle { type: 'Train'; carriages: number; } // 'type: 'Train'' is added
interface Plane extends Vehicle { type: 'Plane'; wingSpan: number; } // 'type: 'Plane'' is added
type PlaneOrTrain = Plane | Train;
function getSpeedRatio(v: PlaneOrTrain) {
if (v.type === 'Train') {
// Inside this 'if' block, TypeScript narrows 'v' to type 'Train' based on 'v.type === 'Train'' check
return v.topSpeed / v.carriages; // Now 'v.carriages' is safely accessible
}
// In the 'else' branch, TypeScript narrows 'v' to type 'Plane'
return v.topSpeed / v.wingSpan; // Now 'v.wingSpan' is safely accessible
}
let bigTrain: Train = { type: 'Train', topSpeed: 100, carriages: 20 };
console.log(getSpeedRatio(bigTrain)); // Output: 5
TypeScript uses the discriminant property (type
in this example) and the conditional check (v.type === 'Train'
) to narrow down the type of v
within the if
block.
Bonus: TypeScript with React – Type-Safe React Development
TypeScript offers excellent integration with React, bringing type safety to your React components and applications. You can use TypeScript with popular React frameworks like Create React App, Next.js, and Remix.
Setting up React with TypeScript:
-
Create React App:
npx create-react-app my-app --template typescript # or yarn create react-app my-app --template typescript
-
Next.js: Next.js has built-in TypeScript support. Simply create
.ts
or.tsx
files in your Next.js project. -
Remix: Remix also has excellent TypeScript support.
These frameworks handle much of the TypeScript configuration automatically. If you need more custom configuration, you can set up Webpack and configure tsconfig.json
manually, but frameworks simplify the process for most projects.
File Extensions for React with TypeScript:
.ts
: For regular TypeScript files (non-React components)..tsx
: For TypeScript files containing React JSX syntax (React components).
React Props with TypeScript:
Type checking React component props is a major benefit of using TypeScript with React. You define the shape of your props using interfaces or types.
// src/components/Person.tsx
import React from 'react';
// Define props interface
interface Props {
name: string;
age: number;
}
// React functional component with type-checked props
const Person: React.FC<Props> = ({ name, age }) => {
return (
<div>
<div>{name}</div>
<div>{age}</div>
</div>
);
};
export default Person;
In this example, React.FC<Props>
defines Person
as a functional component that accepts props conforming to the Props
interface.
Importing and Using Components with Type Checking:
When you import and use the Person
component in App.tsx
(or another component), TypeScript will enforce prop type checking:
// App.tsx
import React from 'react';
import Person from './components/Person';
const App: React.FC = () => {
return (
<Person name="Alice" age={30} /> // Valid: props match interface
// <Person name="Bob" /> // ERROR: Missing 'age' prop (TypeScript error)
);
};
export default App;
More Complex Prop Types:
You can define various prop types, including:
interface PersonInfo { name: string; age: number; }
interface Props {
text: string;
id: number;
isVeryNice?: boolean; // Optional prop
func: (name: string) => string; // Function prop
personInfo: PersonInfo; // Object prop with interface
}
React Hooks with TypeScript:
TypeScript integrates seamlessly with React Hooks.
useState()
with Type Safety:
You can specify the type of your state variables using type arguments with useState<Type>
.
import React, { useState } from 'react';
interface Props { name: string; age: number; }
const Person: React.FC<Props> = ({ name, age }) => {
// State variable 'cash' can be a number or null
const [cash, setCash] = useState<number | null>(100); // Initial state is 100 (number)
setCash(null); // Valid: setting state to null (allowed by union type)
// setCash('broke'); // ERROR: Argument of type '"broke"' is not assignable to parameter of type 'SetStateAction<number | null>'.
return (
<div>
<div>{name}</div>
<div>{age}</div>
<div>Cash: ${cash}</div>
</div>
);
};
If you omit the type argument (e.g., useState(100)
), TypeScript will often infer the state type based on the initial value. However, explicitly specifying the type with <number | null>
provides clearer type safety, especially for union types or when the initial value doesn’t fully represent all possible state values.
useRef()
with Type Safety:
useRef
creates a mutable ref object that persists across renders. You can specify the type of the ref’s current
property using type arguments.
import React, { useRef, useEffect } from 'react';
const MyComponent: React.FC = () => {
// 'inputRef' is a ref object whose 'current' property will hold an HTMLInputElement or null
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus(); // Safe to access 'focus' method because of type annotation
// inputRef.current.value = 5; // ERROR: Property 'value' does not exist on type 'HTMLInputElement'. (if you intended to use a generic HTMLElement, not specifically HTMLInputElement)
}
}, []);
return (
<input type="text" ref={inputRef} />
);
};
By using <HTMLInputElement | null>
, you tell TypeScript that inputRef.current
can be either an HTMLInputElement
or null
. This allows TypeScript to provide accurate type checking when you interact with the ref object.
For more in-depth guidance on using React with TypeScript, explore these excellent React-TypeScript cheat sheets.
Useful Resources and Further Learning for TypeScript
- Official TypeScript Documentation: https://www.typescriptlang.org/docs/ – The definitive source for all things TypeScript.
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/ – A comprehensive guide to TypeScript’s features and concepts.
- TypeScript Deep Dive: https://basarat.gitbook.io/typescript/ – An in-depth exploration of TypeScript by Basarat Ali Syed.
- React TypeScript Cheatsheets: https://react-typescript-cheatsheet.netlify.app/ – Practical examples and guidance for using TypeScript with React.
- FreeCodeCamp News TypeScript Articles: https://www.freecodecamp.org/news/tag/typescript/ – A collection of TypeScript tutorials and articles on FreeCodeCamp News.
Thank You for Learning TypeScript!
Hopefully, this comprehensive guide has been helpful in your journey to learn TypeScript. If you’ve made it this far, you now have a solid foundation in TypeScript fundamentals and are ready to start using it in your projects.
As a reminder, you can download the one-page TypeScript cheat sheet PDF or order the physical poster for quick reference.
For more content, you can find me on Twitter and YouTube.
Happy coding!