Advance Data Types In Typescript - Interfaces and Classes

by iamvkr on

In the previous post we have discussed about the basics of typescript. Now we will be continuing with few more cool stuff of typescript.

Table of contents

Table of contents generated with markdown-toc

Interface

An interface in TypeScript is a way to define the structure of an object, specifying what properties and methods it should have.

Interfaces don’t hold data or logic, they just provide a blueprint.

Interface is just like a Type.

So why do we need interfaces?

Although they look same but there are few differences b/w interfaces as it provides much more flexiblity.

interface Person {
  name: string;
  age: number;
}
const john: Person = { name: "John", age: 30 };

Example of a complex declaration of interface

interface User {
    readonly _id: number, /** read only property*/
    uName: string,
    password: number,
    greet: () => string, /** method that return string */
    getDbId(): number, /** another way of definig method that return string */
    getCoupon(cName: string): number, /** method with parameters */
    address?:string /** optional property*/
}
const user1: User = {
    _id: 13,
    uName:"alice",
    password:1234,
    greet:()=>{return "Hello"},
    getDbId:()=>54,
    getCoupon:("CODE10")=>{return 10}
}

Automatic merges in interface - Reopening of interface (fancy term)

In typescript, you can define an interface multiple times, and TypeScript will merge them into a single one. From above example, let’s add another interface below User interface:

interface User {
  age: number;
}
/** now user must have age  */
const user1: User = {  
    _id: 13, 
    uName:"alice", 
    password:1234,
    greet:()=>{return "Hello"},
    getDbId:()=>54,
    getCoupon:("CODE10")=>{return 10}
    age: 25 
}; // now Works fine!

Inheritance in Interface

Yes. You heard it right. we can apply concepts on inheritance in interfaces: Inheritance is a way to inherit the properties and methods defined from one interface or class (as both are blueprint of an object), to another interface.

interface Admin extends User {
    role: "admin"
}
/** we still have all the properties from user but */
/** now user must have role property as well */
const admin1: Admin = { ..., role:"admin" }; 

Class with interfaces

Class can be created using interfaces, to provide well structure to the class:

interface Camera {
    mode: string,
    quaity: string,
    burst: number
}

interface CameraVideo {
    size: number,
    record(): void
}
/** samsung class is defined with Camera interface */
class Samsung implements Camera {
    constructor(
        /** must have properties from Camera */
        public quaity: "HD" | "SD",
        public burst: number,
        /** additionally can also have more properties */
        public pixels: number
    ) { }
}
/** Apple class is defined with Camera + CameraVideo interface */
class Apple implements Camera, CameraVideo {
    constructor(
        public quaity: "HD" | "SD",
        public burst: number,
        public size: number, /** required from CameraVideo */
    ) { }

    /** required from CameraVideo */
    record(): void {
        console.log("camera is recording..");

    }
}

const s24 = new Samsung("HD",64,108);
const ipone12 = new Apple("HD",56,1080);
iphone12.record();

More on classes:

class User {
    constructor(
        public name: string,
        public email: string,
        public age: number
    ) {
        this.name = name;
        this.email = email;
        this.age = age;
    }
    getUserName = (): string => {
        return this.name;
    }
    incrementAgeBy = (num:number):void=>{
        this.age++;
        console.log(this.age);
    }
}

const u1 = new User("sam","sam@sam.com",20);
console.log(u1.getUserName());
u1.incrementAgeBy(2);

Access Modifiers : Public, Private, Protected

class BankAccount{
    accName = "John Noe"; // default is public
    private balance = 1000; // only accessible to this class so no one can directly change this value
    protected loanAmount = 100; //accessible to class extending BankAccount
    getBalance(){
        console.log(this.balance)
    }
}
class BankLoan extends BankAccount{
    getLoanAmount = ()=>{
        return this.loanAmount;
    }
}

const acc = new BankAccount();
acc.accName = "John Doe"; //allowed
acc.balance = 5000;// error
const johnLoan = new BankLoan();
console.log(johnLoan.accName);
console.log(johnLoan.getLoanAmount());

Getters and Setter

class User{
    public username:string;
    constructor(
        username:string
    ){
        this.username = username;
    }
    // getter
    get userName(){
        return this.username;
    }
    // setter
    set userName(name:string){
        this.username = name;
        console.log("set new user name");
    }
}
const u1 = new User("Sam");
console.log(u1.userName); //get value
u1.userName = "SAM"; //set value

Fun fact: Getter and setter do not need to be same in ts, for example we can use get userName and set userNAME and it won’t throw error, but is recommended to use same name for better readiblity

Abstract Class

An abstract class in TypeScript is a class that cannot be instantiated directly(object cannot be cretaed directly with new keyword).

It is meant to be extended by other classes. Abstract classes can contain abstract methods (methods without implementation) that must be implemented by any non-abstract subclass. (That was a lot of words to undertand)

Let's simplify it

Imagine you want to create a blueprint for different types of API. Different types of APIs need to handle different requests. Some API might have specific behaviors, but others might share common properties.

So, you create an abstract class API that contains common properties and methods, like the baseUrl and sendRequest(). The AuthClass will extend this class and implement its own authentication logic.

The abstract class is like a basic plan for the API, but it’s not something you can use directly. You need to build more specific classess that follow the plan.

abstract class API {
    protected key: "api_key";
    constructor(
        public url: string,
    ) { }

    // authenticate():void; /** ERR: Function implementation is missing or not immediately following the declaration  */
    /** Abstract method for authentication (not defined here, each subclass must define it) */
    abstract authenticate(): void;

    // Common method for making API requests (this is shared by all APIs)
    sendRequest(endpoint: string): void {
        console.log(`Sending request to ${this.url}${endpoint}`);
    }
}

class AuthClass extends API {
    constructor(
        public url: string,
    ) {
        super(url); /** make sure to call super */
    }
    // Implements the authenticate method here:
    authenticate(): void {
        console.log(`Authenticating ...`);
    }
    /** the function implementation is already defined in API class and can be overriden but not required 
    sendRequest(endpoint: string): void { 
        console.log(`redirected request to ${this.url}${endpoint}`);
    };
    */
}

// const api = new API() /** err: Cannot create an instance of an abstract class */
const auth = new AuthClass("https://example.com");
console.log(auth.authenticate())
console.log(auth.sendRequest("/user"))

Using Abstract class we can achive flexible Authentication: The authenticate() method is left abstract, so each API can define its own authentication logic.

You could create other subclasses that extend the API class, like an OAuthAPI class, and implement the authentication in a different way:

class OAuthAPI extends API {
  private clientId: string;
  private clientSecret: string;

  constructor(url: string, clientId: string, clientSecret: string) {
    super(baseUrl);
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }

  authenticate(): void {
    console.log(`Authenticating with OAuth using Client ID: ${this.clientId}`);
  }
}

Difference bw types and interface

Featureinterfacetype
ExtendingCan extend other interfaces (extends)Use intersection (&) for merging types
MergingSupports declaration mergingDoes not support declaration merging
Use CasesPrimarily for object shapesUsed for objects, unions, intersections, etc.
DeclarationCan be declared multiple timesCannot be redeclared

Generic Types

Type generics in TypeScript are a way to define reusable components, while allowing them to work with different types without losing type safety.

Here’s an example of a simple function that uses generics:

suppose you need to write a function, where you want first element of given array, You could do:

function getFirstElementNumber(arr:number[]):number {
    return arr[0]
}
/** but what for string ? */ 
function getFirstElementString(arr:string[]):string {
    return arr[0]
}
/** but what for both string and number ? */ 
function getFirstArrValue(arr:number|string):(number|string){
    return arr[0];
}
/** Now you can see above it's getting complicated for all types */
/** Let's fix by using generics */
function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const numArr = [1, 2, 3];
const strArr = ['apple', 'banana', 'cherry'];

console.log(getFirstElement(numArr));  // Output: 1
console.log(getFirstElement(strArr));  // Output: 'apple'

T is a type placeholder that gets replaced with the type of data that the function is working with.

In the above example, T[] means an array of any type, and the return type is the same type as the first element in the array (T).

Why it’s Useful:

  • The function getFirstElement can now work with arrays of any type (numbers, strings, etc.).
  • We don’t have to rewrite the function for each type like getFirstElementNumber or getFirstElementString.

Another example with class

/** Let's say we want to create a Stack class: */

class Stack<T> {
  private items: T[] = [];
  
  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }
}

const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // Output: 20

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.pop()); // Output: "world"

More on generics

function getProducts<T>(products:T[]):T {
    const index = 4;
    return index; /** ERR!! */
    return products[index]; /** WORKS FINE */
}

In above code: Returning a number (index) is incompatible with the expected return type (T). The compiler doesn’t know that index is of the same type as the elements in the products array. It assumes that T could be anything, and since index is a number, this leads to a type mismatch error.

But

products[index] retrieves an element from the products array at the index position. The element at products[index] is of type T (because the array products is of type T[]). This matches the function signature getProducts(products: T[]): T, and hence, no error occurs.

Narrowing

It’s a process of refining a type within a certain scope based on a condition. It allows TypeScript to infer and restrict a variable’s type more precisely when certain checks or conditions are applied.

Type Narrowing

for example:

function setWidth1(input: number | string): void {
    // const width = input + 10; //ERR
    /** Operator '+' cannot be applied to types 'string | number' and 'number'. */
    /** how do we know if the input we have is a number or string ? */
}

To solve above issue, we can narrow down the type of a parameter based on whether it’s a string or a number.

function setWidth(input: number | string): void {
    let width: number;

    if (typeof input === 'string') {
        // If input is a string, remove any non-numeric characters (like 'px')
        width = parseFloat(input);
    } else {
        // If input is a number, just use it directly
        width = input;
    }
    console.log(width + 10); // Here width becomes a complete number only
}
setWidth(60);         // Logs: 70
setWidth('60px');     // Logs: 70
setWidth('100%');     // Logs: 110
setWidth('150rem');   // Logs: 160

Truthiness narrowing

It uses concept of truthiness and falsy values to narrow down the types:

Values like: 0 NaN "" (the empty string) 0n (the bigint version of zero) null undefined all coerce to false

function printMessage(msg:(string|null)){
    if(msg){
        console.log(msg.toLowerCase());
    }else{
        console.log("Invalid Input");
    }
}
printMessage(null);
printMessage("HELLO");

MORE ON: https://www.typescriptlang.org/docs/handbook/2/narrowing.html

Using typescript in a web app

Create a new folder ts-html and create a new file index.html inside the folder. Then crreate a script.ts

Write the code in index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello Ts</title>
</head>
<body>
    <div>Hello TS</div>
    <script src="./script.js"></script>
</body>
</html>

script.ts:

console.log("Hello from TS");

next we need to compile ts to js, so we need to install typescript (ignore if already installed globally)

npm i typescript -g

Transppile to js:

tsc script.ts

Next, run your index.html with a live server. You have successfully integrated ts in your web application.

Hope you enjoyed reading this post, with a complete information about typescript. You can bookmark or share the post. Comment your thoughts below if you liked reading the post!