Prologue
A wise developer (named Utkarsh) once said that if you want to become a successful Javascript developer, learn Typescript!
Following this mantra, we're today going to leave our JS realm to get ourselves into the new realm of Typescript.
Introduction
If you're a JS dev, you would have probably already heard Typescript a lot. For those who have not, fear not I'm here.
Typescript is basically a superset of JavaScript. It adds static type definitions in your code allowing you to define the types of variables, functions, and objects in your code. This helps to catch errors early on and make your code more maintainable. TypeScript also adds some new features, such as classes, interfaces, and generics.
Note: A valid JavaScript code is also a valid TypeScript code. However, vice versa is not true!
Remember that your browser does not understand Typescript. Thus, it is a common practice to compile TS code into JS before deploying it.
Why do we even need Typescript ?
JavaScript is a loosely typed language. So, it can be difficult to understand what types of data are being passed around in JavaScript.
In JS, function parameters and variables don't have any specific types. So, you may even not realize if you're passing wrong data in a function. This may result in alot of runtime errors.
TypeScript allows specifying the types of data being passed around within the code, and has the ability to report errors when the types don't match.
Configuring the TS project
To get started with Typescript, you need to have NodeJS installed in your system. Once NodeJS, is installed and all configured, use the below given command to install Typescript compiler via NPM.
npm install typescript --save-dev
To compile your TS code into JS, you can use the following command-
npx tsc --build
You can even add a tsconfig.json file in your project directory to override default behavior of Typescript. Follow this link to know more about tsconfig.json file.
The Basics
Type Assignment
In Typescript, you can assign types to a variable in two ways:-
Implicit - TS guesses and assigns a type to a variable based on a given value.
Explicit - You explicitly provide the type to your variable.
let name1: string = "Utkarsh"; // Explicit Type Assignment
let name2 = "Utkarsh" // Implicit Type Assignment
// if we try to re-assign name1 or name2 to a different datatype,
// it would result in an error.
// For example:-
name2 = true; // error
Note: Having TypeScript "guess" the type of a value is called infer. Implicit assignment forces TypeScript to infer the value.
Sometimes, TS may not be able to infer the type in case of Implicit Type Assignment. In such cases, TS will assign the type "any" to the variable. However, this behavior can be disabled using tsconfig.json.
Basic Types
TS has built-in support for the basic types such as string, number, and boolean. Since we've already seen how we can use these types in the above example, we'll not be discussing them here.
Literal Types
TS allows you to specify a fixed value for a variable or property. This fixed value can be a string, a number, a boolean or even null or undefined.
// String Literal type -
let color: "red" | "green" | "blue";
color = "red"; // Valid
color = "yellow"; // Error
// Numerical Literal type -
let diceFace: 1 | 2 | 3 | 4 | 5 | 6;
diceFace = 3; // Valid
diceFace = 7; // Error
// Boolean Literal type -
let isTrue: true;
isTrue = true; // Valid
isTrue = false; // Error
// Undefined Literal type -
let nullValue: null;
nullValue = null; // Valid
nullValue = 42; // Error
Special Types: any, unknown, never and void
any - Any is a special type that completely disables type checking and effectively allows all types to be used for a given variable.
let var1: any = "Test"; var1 = 1000; // this does not give error as we used "any" type
Remember, while "any" can useful to get past TS errors. However, this type also disables type safety and auto completion.
It is usually advised not to use "any" in your code.
unknown - This type can be thought of as a safer alternative to "any". It can be used whenever we're not sure about the type or the structure of data being received. For example, "unknown" can be used when we fetch some data from an API endpoint.
let var1: unknown = "test"; var1 = true; // no error again
The main difference between any and unknown is that : any allows you to use all JS methods (such as valueOf(), toLowerCase(), etc) on the variable.
However, unknown does not allow you to use these methods without type assertion. You can learn more about unknown here.
never - This type effectively throws an error whenever it is defined.
let x: never = true; // Error: Type 'boolean' is not assignable // to type 'never'. // never is rarely used in a codebase. It is primarily used // in advanced generics.
void - Void is a special type that is used when there is no data. For example, when a given function does not return anything, we can use void as its return type.
function myFunc(): void { console.log("Hello World !"); }
Tuples, Enums and Object Types
Tuples
Tuples are the typed arrays in TS. They have a fixed length and fixed types for each index.
In practical use cases, Tuples are generally made readonly because Tuples only have strongly defined types for the initial values.
Tuples can also be created as named tuples by adding an identifier for each of the values. It essentially helps during destructuring.
let myTuple: [number, string, boolean];
myTuple = [1, "hello", true]; // valid tuple
myTuple.push("new value");
// no type safety for index >= 3.
// Better to make them readonly
// Readonly Tuples -
const newTuple: readonly [number, string, boolean] = [1, "hello", true];
newTuple.push("new value"); // throws error
// Named Tuples
const coordinates: [x: number, y:number] = [80, 79];
Enums
In TS, an enum is a special "class" that represents a group of constants. They offer better type safety than JS constant objects.
// Non Initialized Enums (returns the index) -
enum StatusCodes {
NotFound,
Success,
Accepted,
BadRequest
};
console.log(StatusCodes.Accepted); // prints 2
// Initialized Enums
enum Directions {
UP = 'UP',
DOWN = "DOWN",
LEFT = "LEFT",
RIGHT = "RIGHT"
};
console.log(Directions.UP); // prints 'UP'
Object
The Objects in Typescript can be initialized with a given set of properties -
type Person = {
name: string,
age: number
}
const p1: Person = {
name: "test",
age: 31,
}
Note: To destructure tuples or objects, we can simply use Array/ Object destructuring. Since, Array/ Object destructuring in TS is exactly same as in JS, it is not covered here.
Interfaces and Type Alias
In TS, we can define our types separately from the variables that use them. This allows these Aliases and Interfaces allows types to be easily shared between different variables and objects.
Type Alias - Type Aliases allow us to define types with a custom name (i.e., an Alias). They can be used for primitives like
string, number
or more complex types such asobjects
andarrays.
Interfaces - Interfaces are similar to type aliases, except they only apply to
object
types. Further, Interfaces can be extended just like classes.
// type alias -
type PersonAge = number; // simple type
type Person = {
name: string,
age: number
}; // complex type
const age: PersonAge = 12;
const p1: Person = {name: "john wick", age: 31};
// interfaces
interface Car {
model: string;
year: number;
price: number;
}
const nexaCar: Car = {model: "fronx", year: 2023, price: 800000};
// extending an interface
interface BasePerson {
name: string;
age: number;
}
interface Employee extends Person {
salary: number;
department?: string; // using '?' marks the property as optional.
}
const emp1: Employee = {
name: "Mark Spectre",
age: 28,
salary: 0,
};
Note: '?' can be used for marking a property as optional in both Type Alias and Interfaces.
parameter?: type
is a shorthand forparameter: type | undefined
.Did you know - Type Alias cannot have cyclic objects whereas interfaces can!
Unions and intersections
Unions and intersections can be thought of as the Logical AND and OR operators for Alias and Interfaces in TS.
Union
Unions can be seen as Logical OR in TS. For example, when we expect a parameter or a variable to be of one of the many available types, we can use a union.
For example, consider - let var: string | number = 10;
However, it is important to note that unions behave a bit different as one may expect in case of interfaces. For clarity, consider the below example -
interface Dog {
bark(): void;
eat(): void;
}
interface Fish {
swim(): void;
eat(): void;
}
const animal: Dog | Fish = new Animal();
animal.bark();
// gives error - Property 'bark' does not exist on type 'Dog | Fish'.
animal.eat(); // works perfectly
// in case of unions for interfaces, we can use only the common
// properties.
Now that you know about unions, there is a practical way of using unions known as Discriminating Unions. They allow you to model a fixed set of related types. All the different types in the union must contain a common property that helps differentiating between them. This property is called a discriminant.
// Define a common property (discriminant)
type ShapeType = "circle" | "square" | "triangle";
// Define individual shape interfaces with discriminant property
interface Circle {
type: "circle"; // discriminant
radius: number;
}
interface Square {
type: "square"; // discriminant
sideLength: number;
}
interface Triangle {
type: "triangle"; // discriminant
sideA: number;
sideB: number;
sideC: number;
}
// Create a discriminating union type using the shape interfaces
type Shape = Circle | Square | Triangle;
Intersections
Intersections are closely related to unions. They combines multiple types into a single type. This allows you to add together existing types to get a single type that has all the features you need.
type Dog = {
name: string;
breed: string;
bark: () => void;
};
type Robot = {
serialNumber: string;
move: () => void;
};
// Define a new type by intersecting Dog and Robot types
type DogRobot = Dog & Robot;
// Create an object that conforms to DogRobot type
const myDogRobot: DogRobot = {
name: "Buddy",
breed: "Golden Retriever",
bark: () => {
console.log("Woof!");
},
serialNumber: "123456",
move: () => {
console.log("Walking forward");
},
};
// Now, you can use myDogRobot with all properties
myDogRobot.bark(); // Outputs: Woof!
myDogRobot.move(); // Outputs: Walking forward
// some interesting cases
// -- let us now try to declare a variable -
const x: string & number = 10;
// what would be the output here? In case you haven't expected
// this results in 'x' having the type 'never' and gives us error
// as a variable with type 'never' can never be initialized.
Type Narrowing
Type narrowing is the process of refining the type of a variable block based on runtime checks. TS uses control flow analysis to narrow down the type of a variable within conditional statements, making the type system more expressive and allowing for more accurate type inference.
There are various ways to perform type narrowing such as -
Type Guards: Type guards are functions or expressions that help TypeScript narrow down the type of a variable within a certain scope. The most common type guards include
typeof
,instanceof
, and user-defined type guards.function someFunction(value: string | number): string { if (typeof value === "string") { // value is narrowed to string inside this block return value.toUpperCase(); } else { // value is narrowed to number inside this block return value.toFixed(2); } }
Truthy/Falsy Checks: You can use truthy and falsy checks to narrow down the type based on the value's truthiness.
function someFunction(value: string | null): string { if (value) { // value is narrowed to string (truthy) inside this block return value.toUpperCase(); } else { // value is narrowed to null (falsy) inside this block return "Default"; } }
Custom Type Predicates: You can create custom type predicates to explicitly specify type narrowing rules.
interface Car { make: string; model: string; } function isCar(obj: any): obj is Car { return obj && typeof obj.make === 'string' && typeof obj.model === 'string'; } function exampleCustomTypePredicate(obj: Car | string): string { if (isCar(obj)) { // obj is narrowed to Car inside this block return `${obj.make} ${obj.model}`; } else { // obj is narrowed to string inside this block return obj.toUpperCase(); } }
Generics
Generics in TS allow us to create flexible and reusable functions, classes, and interfaces without sacrificing type safety. They operate by using placeholders called type parameters that can be replaced with specific types later when using the generic code.
Some Key Concepts
Type Parameters: Represented by letters like
T
,U
,K
, etc. They stand for placeholder types that will be replaced with concrete types when the generic code is used.Generic Constraints: Rules that restrict the types that can be used for type parameters. This ensures type safety and prevents misuse.
Variance: Controls how type parameters are affected by subtyping relationships. Invariants are the most common choice, ensuring consistency across type relationships.
Below given are some examples of generics in Typescript -
// example 1
interface Sports {
name: string;
}
function printSportName<T extends Sports>(sport: T): void {
console.log(sport.name);
}
let sport: Sports = { name: "baseball" };
printSportName(sport);
// example 2
Let's get started
Lets try to understand the below given example -
function returnValue<T>(value: T): T {
return value;
}
const num = returnValue(42); // num is number
const str = returnValue("hello"); // str is string
In this example, it is evident that the Type Parameter T takes the value of either string or number based on the type of argument passed.
Since now this example is clear, let's try to decode a more complex case-
class Pair<T, U> {
private first: T;
private second: U;
constructor(first: T, second: U) {
this.first = first;
this.second = second;
}
getFirst(): T {
return this.first;
}
getSecond(): U {
return this.second;
}
}
const pair1 = new Pair<number, string>(1, "apple");
const pair2 = new Pair<boolean, object>(true, { name: "John" });
The Utility Types
TS, by default, provides us some predefined type functions that provide convenient transformations and operations on existing types. These predefined type functions are known as Utility Types.
They allow you to create new types based on existing types by modifying or extracting properties, making properties optional or required, creating immutable versions of types, and more.
A complete guide to Utility Guides is given here.
Advanced Concepts
infer
infer
keyword is used in the context of conditional types to infer and capture the type of a variable within the true branch of a conditional type. It allows you to create more flexible and generic type conditions.
Consider the following example -
type ExtractString<T> = T extends string ? T : never;
type MyStringType = ExtractString<"Hello">; // MyStringType is "Hello"
type NotAStringType = ExtractString<number>; // NotAStringType is never
Now, what if instead of string, you had an object? How would you apply conditional constraints in that case? To solve this issue, we use infer.
type ExtractProps<T> = T extends { props: infer P } ? P : never;
type ComponentProps = { props: { id: number; name: string } };
type ExtractedProps = ExtractProps<ComponentProps>; // ExtractedProps is { id: number; name: string }
The infer
keyword is particularly useful when you want to extract or manipulate nested types within a conditional type. It allows you to capture and reuse these inferred types in a more flexible way.
keyof and typeof
keyof - This TS operator that is used to obtain the union type of all possible property names of a given type.
typeof - This TS operator is used to obtain the type of a variable, expression, or value at compile time. It is a way to refer to the type of a variable without actually creating an instance of that type.
// keyof
type Person = {
name: string;
age: number;
address: string;
};
type KeysOfPerson = keyof Person; // "name" | "age" | "address"
// typeof
const str = "Hello, TypeScript!";
let myVariable: typeof str; // myVariable has type string
myVariable = "New value"; // Valid
myVariable = 42; // Error
const myObject = {
name: "John",
age: 30,
address: "123 Main St",
};
function getProperty(obj: typeof myObject, key: keyof typeof myObject) {
return obj[key];
}
const nameValue = getProperty(myObject, "name"); // "John"
Satisfies Operator
he satisfies
operator provides a way to check if a given type meets the requirements of a specific interface or condition while preserving the exact type during inference. This is particularly useful when dealing with values that can have different concrete types but share a common structure.
It is usually used with -
Discriminated unions
interface Shape { kind: 'circle' | 'rectangle'; } interface Circle extends Shape { radius: number; } interface Rectangle extends Shape { width: number; height: number; } function getArea(shape: Shape) { if (shape.kind === 'circle') { // Access radius if satisfies Circle if (shape satisfies Circle) { return Math.PI * shape.radius * shape.radius; } // Handle incorrect type here } else if (shape.kind === 'rectangle') { // Access width and height if satisfies Rectangle if (shape satisfies Rectangle) { return shape.width * shape.height; } } }
Complex object checks
interface UserBase { name: string; email: string; } interface UserWithAge extends UserBase { age: number; } interface UserWithLocation extends UserBase { location: string; } type UserProfile = UserBase | UserWithAge | UserWithLocation; function processUserProfile(profile: UserProfile) { if (profile satisfies UserBase) { // Access name and email safely console.log(profile.name, profile.email); // Optionally check for additional properties if (profile satisfies UserWithAge) { console.log("Age:", profile.age); } if (profile satisfies UserWithLocation) { console.log("Location:", profile.location); } } }
Function parameter validation
function processData(data: { id: string; value: unknown }) { if (data satisfies { value: number }) { // Use value as a number } else if (data satisfies { value: string }) { // Use value as a string } else { // Handle other value types } }
as const
This operator is used for enforcing Immutability to objects or array literals. It also makes the objects/array literals read-only. This means you cannot modify its properties or the order of its elements after creation.
const person = {
name: "Alice",
age: 30
} as const;
// person now has type { name: 'Alice', age:30 }
using
using
plays a specific role in resource management, focusing on ensuring proper disposal of resources like file handles, database connections, etc. while improving code readability and safety.
It takes care of automatic disposal of resources when they are no longer needed, simplifying resource management and preventing potential memory leaks or other issues.
import * as fs from 'fs';
function readFile(path: string) {
using (const file = fs.openSync(path, 'r')) {
const buffer = fs.readFileSync(file);
// Use the buffer
console.log(buffer.toString());
} // The file gets closed here automatically
}
readFile('my-file.txt');
Type Assertions
There are many cases where you have more information about the type of a value that TypeScript can’t know about.
For example, if you’re using document.getElementById
, TypeScript only knows that this will return some kind of HTMLElement
, but you might know that your page will always have an HTMLCanvasElement
with a given ID.
In such cases, you can use Type Assertions. For example -
const myCanvas1 = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas2 = <HTMLCanvasElement>document.getElementById("main_canvas");
Index Types, Mapped Types and Conditional Types
Index Types - When we represent the types of properties within an object based on their keys, they're called index types. We often use square brackets and a key type within them to define the relationship.
interface User { [key: string]: string | number; // Properties can have string or number values } const user: User = { name: "Alice", age: 30 }; // user.name is inferred as string, user.age as number
Mapped Types - When we create new types by iterating over properties of existing types, they're called mapped types. They usually follow this syntax -
type NewType = { [Key in OldTypeKey]: NewValueType }
type Readonly<T> = { readonly[Key in keyof T]: T[Key] }; // Make all properties readonly type User = { name: string, age: number, } type ReadonlyUser = Readonly<User>; const readOnlyUser: ReadonlyUser = { name: "Alice", age: 30 // Error: cannot edit readonly property };
Conditional Types - When we use some conditional logic within mapped types to create different types based on certain conditions, it is called conditional mapping.
interface Animal {
}
interface Dog extends Animal {
}
type Ex1 = Dog extends Animal ? number : string;
//type of Ex1 = number
type Ex2 = RegExp extends Animal ? number : string;
// type of Ex2 = string
Note: Often times, TS needs to gracefully fail when it detects possibly infinite recursion during conditional mappings. In such cases, tail-recursion elimination on conditional types so that TS does not go off the rails to compute deeply nested mappings.
The Awaited Type
TS V4.5 introduced a new utility type called the Awaited
type. They're meant to model operations like await
in async
functions, or the .then()
method on Promises
.
// A = string
type A = Awaited<Promise<string>>;
// B = number
type B = Awaited<Promise<Promise<number>>>;
// C = boolean | number
type C = Awaited<boolean | Promise<number>>;
Conclusion
In conclusion, venturing into the realm of TypeScript proves to be a wise decision for any JavaScript developer aspiring for success.
As a superset of JavaScript, TypeScript not only introduces static type definitions but also enhances code maintainability and early error detection. With features like classes, interfaces, generics, and advanced concepts such as conditional types and index types, TypeScript offers a robust toolkit for developers to build scalable and reliable applications.
As we embrace TypeScript, we open doors to a world of enhanced development experiences, improved collaboration, and ultimately, more successful and resilient software projects.
So, heed the advice of Utkarsh (the wise developer 😏), and embark on your TypeScript journey for a brighter and more productive coding future.