Sitemap

JavaScript + Backend Bootcamp

Day 8 : Advanced TypeScript

11 min readJun 30, 2026

--

Today we move beyond basic TypeScript and learn the features used in production applications.

You’ll learn:

  • Advanced union types
  • Intersection types
  • Type narrowing
  • Type guards
  • Generics
  • Generic constraints
  • Classes
  • Access modifiers
  • Inheritance
  • Abstract classes
  • Utility types
  • keyof
  • Indexed access types
  • Async TypeScript patterns
  • Safer API typing
  • TypeScript best practices

Recommended Reading 📖

Review this article before starting:

From JavaScript to TypeScript: How TypeScript Improves Code Quality

It explains the transition from JavaScript to TypeScript and the importance of type safety.

Day 8 builds on those foundations with advanced and backend-focused TypeScript concepts.

1. Union Types

A union allows a value to have more than one possible type.

let userId: string | number;
userId = 101;
userId = "USER-101";

Function example:

function printId(id: string | number): void {
console.log(`User ID: ${id}`);
}
printId(101);
printId("USER-101");

2. Type Narrowing

When a value has multiple possible types, TypeScript requires you to check its actual type before using type-specific methods.

function formatValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase();
}
  return value.toFixed(2);
}
console.log(formatValue("typescript"));
console.log(formatValue(95.567));

Output:

TYPESCRIPT
95.57

3. Literal Types

Literal types restrict values to specific choices.

type UserRole = "admin" | "editor" | "user";
let role: UserRole = "admin";

This produces an error:

role = "manager";

Backend example:

type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE";
function sendRequest(method: HttpMethod): void {
console.log(`Sending ${method} request`);
}
sendRequest("POST");

4. Discriminated Unions

Discriminated unions help TypeScript identify different object structures safely.

type SuccessResponse = {
status: "success";
data: string[];
};
type ErrorResponse = {
status: "error";
message: string;
};
type ApiResponse = SuccessResponse | ErrorResponse;

Use it safely:

function handleResponse(response: ApiResponse): void {
if (response.status === "success") {
console.log(response.data);
} else {
console.log(response.message);
}
}

This pattern is useful for:

  • API responses
  • Application states
  • Authentication results
  • Payment status
  • Database operations

5. Intersection Types

Intersection types combine multiple types.

type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: number;
department: string;
};
type StaffMember = Person & Employee;

Example:

const employee: StaffMember = {
name: "Ravi",
age: 22,
employeeId: 101,
department: "Engineering"
};
console.log(employee);

6. Type Guards

Type guards help identify a value’s type.

Using typeof

function display(value: string | number): void {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}

Using in

type Admin = {
name: string;
permissions: string[];
};
type Customer = {
name: string;
orders: number;
};
function showUser(user: Admin | Customer): void {
if ("permissions" in user) {
console.log(user.permissions);
} else {
console.log(user.orders);
}
}

Using instanceof

class AppError extends Error {
statusCode: number;
  constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
function handleError(error: Error | AppError): void {
if (error instanceof AppError) {
console.log(error.statusCode);
}
console.log(error.message);
}

7. Custom Type Guards

You can create a function that tells TypeScript which type a value has.

interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
return (
"id" in value &&
typeof value.id === "number" &&
"name" in value &&
typeof value.name === "string"
);
}

Usage:

const data: unknown = {
id: 1,
name: "Ravi"
};
if (isUser(data)) {
console.log(data.name);
}

Custom type guards are useful when working with:

  • API data
  • JSON
  • User input
  • Environment variables
  • Third-party packages

8. Generics

Generics allow reusable code while preserving type safety.

Get Bhaskar Chowdary’s stories in your inbox

Join Medium for free to get updates from this writer.

Without generics:

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

With generics:

function getValue<T>(value: T): T {
return value;
}
const username = getValue<string>("Ravi");
const age = getValue<number>(22);

TypeScript can usually infer the type:

const city = getValue("Hyderabad");
const marks = getValue(95);

9. Generic Arrays

function getFirstItem<T>(items: T[]): T | undefined {
return items[0];
}
const firstNumber = getFirstItem([10, 20, 30]);
const firstFruit = getFirstItem([
"Apple",
"Banana",
"Mango"
]);
console.log(firstNumber);
console.log(firstFruit);

10. Generic Interfaces

interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}

User response:

interface User {
id: number;
name: string;
email: string;
}
const userResponse: ApiResponse<User> = {
success: true,
message: "User fetched successfully",
data: {
id: 1,
name: "Ravi",
email: "ravi@example.com"
}
};

Array response:

const usersResponse: ApiResponse<User[]> = {
success: true,
message: "Users fetched successfully",
data: [
{
id: 1,
name: "Ravi",
email: "ravi@example.com"
}
]
};

This pattern will be heavily used in backend APIs.

11. Generic Constraints

Generic constraints limit which types can be passed.

function printLength<T extends { length: number }>(
value: T
): number {
return value.length;
}
console.log(printLength("TypeScript"));
console.log(printLength([1, 2, 3]));

This produces an error:

printLength(100);

A number does not have a length property.

12. Multiple Generic Types

function createPair<T, U>(
first: T,
second: U
): [T, U] {
return [first, second];
}
const pair = createPair("Ravi", 22);console.log(pair);

The inferred type is:

[string, number]

13. keyof Operator

keyof creates a union of an object type’s property names.

interface User {
id: number;
name: string;
email: string;
}
type UserKey = keyof User;

UserKey becomes:

"id" | "name" | "email"

Example:

function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user: User = {
id: 1,
name: "Ravi",
email: "ravi@example.com"
};
console.log(getProperty(user, "name"));

This produces an error:

getProperty(user, "password");

14. typeof in Type Positions

TypeScript can create a type from an existing value.

const defaultConfig = {
port: 3000,
environment: "development",
logging: true
};
type AppConfig = typeof defaultConfig;

Now:

const productionConfig: AppConfig = {
port: 8080,
environment: "production",
logging: false
};

15. Indexed Access Types

You can access the type of a specific property.

interface Product {
id: number;
name: string;
price: number;
}
type ProductPrice = Product["price"];

ProductPrice is:

number

Multiple properties:

type ProductInfo = Product["name" | "price"];

ProductInfo becomes:

string | number

16. Classes in TypeScript

class User {
name: string;
age: number;
  constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): string {
return `Hello, my name is ${this.name}`;
}
}
const user = new User("Ravi", 22);console.log(user.greet());

17. Constructor Parameter Properties

TypeScript provides a shorter class syntax.

class Product {
constructor(
public name: string,
public price: number
) {}
  display(): string {
return `${this.name}: ₹${this.price}`;
}
}
const product = new Product("Keyboard", 1500);console.log(product.display());

TypeScript automatically creates and assigns the properties.

18. Access Modifiers

TypeScript supports:

  • public
  • private
  • protected

Public

Public members can be accessed anywhere.

class User {
constructor(public name: string) {}
}
const user = new User("Ravi");console.log(user.name);

public is the default.

Private

Private members can only be accessed inside the class.

class BankAccount {
private balance = 0;
  deposit(amount: number): void {
if (amount <= 0) {
throw new Error(
"Deposit amount must be positive"
);
}
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
const account = new BankAccount();account.deposit(1000);console.log(account.getBalance());

This produces an error:

console.log(account.balance);

Protected

Protected members can be accessed inside the class and its child classes.

class Employee {
constructor(
protected salary: number
) {}
}
class Manager extends Employee {
showSalary(): number {
return this.salary;
}
}
const manager = new Manager(50000);console.log(manager.showSalary());

19. Readonly Class Properties

class User {
constructor(
public readonly id: number,
public name: string
) {}
}
const user = new User(1, "Ravi");user.name = "Arun";

This produces an error:

user.id = 2;

20. Getters and Setters

class Employee {
private _salary = 0;
  get salary(): number {
return this._salary;
}
set salary(value: number) {
if (value < 0) {
throw new Error(
"Salary cannot be negative"
);
}
this._salary = value;
}
}
const employee = new Employee();employee.salary = 50000;console.log(employee.salary);

21. Inheritance

Inheritance allows one class to reuse another class.

class Person {
constructor(public name: string) {}
  greet(): string {
return `Hello, I am ${this.name}`;
}
}
class Admin extends Person {
constructor(
name: string,
public permissions: string[]
) {
super(name);
}
showPermissions(): string[] {
return this.permissions;
}
}
const admin = new Admin(
"Ravi",
["create", "update", "delete"]
);
console.log(admin.greet());
console.log(admin.showPermissions());

22. Method Overriding

A child class can replace a parent method.

class User {
getRole(): string {
return "User";
}
}
class Admin extends User {
override getRole(): string {
return "Admin";
}
}
const admin = new Admin();console.log(admin.getRole());

23. Abstract Classes

An abstract class cannot be instantiated directly.

abstract class Payment {
constructor(public amount: number) {}
  abstract process(): string;  displayAmount(): string {
return `Amount: ₹${this.amount}`;
}
}

Child class:

class CardPayment extends Payment {
process(): string {
return `Card payment of ₹${this.amount} processed`;
}
}
const payment = new CardPayment(2000);console.log(payment.process());

This is not allowed:

const payment = new Payment(2000);

24. Implementing Interfaces

Classes can implement interfaces.

interface Authenticatable {
login(): boolean;
logout(): void;
}
class AdminUser implements Authenticatable {
login(): boolean {
console.log("Admin logged in");
return true;
}
logout(): void {
console.log("Admin logged out");
}
}

An implementing class must provide all required members.

25. Utility Type: Partial<T>

Partial makes every property optional.

interface User {
name: string;
email: string;
age: number;
}
function updateUser(
user: User,
updates: Partial<User>
): User {
return {
...user,
...updates
};
}
const user: User = {
name: "Ravi",
email: "ravi@example.com",
age: 22
};
const updatedUser = updateUser(user, {
age: 23
});
console.log(updatedUser);

Useful for:

  • PATCH requests
  • Update forms
  • Partial database updates

26. Utility Type: Required<T>

Required makes all properties mandatory.

interface UserSettings {
theme?: string;
notifications?: boolean;
}
const settings: Required<UserSettings> = {
theme: "dark",
notifications: true
};

27. Utility Type: Readonly<T>

Readonly prevents property updates.

interface Config {
port: number;
environment: string;
}
const config: Readonly<Config> = {
port: 3000,
environment: "development"
};

This produces an error:

config.port = 5000;

28. Utility Type: Pick<T, K>

Pick creates a type using selected properties.

interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = Pick<
User,
"id" | "name" | "email"
>;
const publicUser: PublicUser = {
id: 1,
name: "Ravi",
email: "ravi@example.com"
};

Useful for hiding sensitive fields.

29. Utility Type: Omit<T, K>

Omit removes selected properties.

interface User {
id: number;
name: string;
email: string;
password: string;
}
type SafeUser = Omit<User, "password">;

Example:

const safeUser: SafeUser = {
id: 1,
name: "Ravi",
email: "ravi@example.com"
};

30. Utility Type: Record<K, T>

Record creates an object with defined keys and value types.

type UserRole = "admin" | "editor" | "user";
type RolePermissions = Record<
UserRole,
string[]
>;
const permissions: RolePermissions = {
admin: ["create", "read", "update", "delete"],
editor: ["create", "read", "update"],
user: ["read"]
};

31. Utility Type: Exclude<T, U>

Exclude removes members from a union.

type Status =
| "pending"
| "success"
| "failed"
| "cancelled";
type ActiveStatus = Exclude<
Status,
"cancelled"
>;

ActiveStatus becomes:

"pending" | "success" | "failed"

32. Utility Type: ReturnType<T>

ReturnType extracts a function’s return type.

function createUser() {
return {
id: 1,
name: "Ravi",
active: true
};
}
type CreatedUser = ReturnType<
typeof createUser
>;

33. Handling Errors Safely

With strict TypeScript settings, caught errors should be treated carefully.

try {
throw new Error("Something went wrong");
} catch (error: unknown) {
if (error instanceof Error) {
console.log(error.message);
} else {
console.log("Unknown error");
}
}

Avoid:

catch (error: any) {
console.log(error.message);
}

34. Typed Async Functions

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

Typed array response:

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

35. Generic API Response

interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}

Function:

async function fetchData<T>(
url: string
): Promise<ApiResponse<T>> {
const response = await fetch(url);
  if (!response.ok) {
throw new Error(
`Request failed with ${response.status}`
);
}
return response.json() as Promise<
ApiResponse<T>
>;
}

Usage:

interface User {
id: number;
name: string;
email: string;
}
async function loadUsers(): Promise<void> {
try {
const response = await fetchData<User[]>(
"/api/users"
);
console.log(response.data);
} catch (error: unknown) {
if (error instanceof Error) {
console.log(error.message);
}
}
}

Remember: TypeScript assertions do not validate API data at runtime. We will later use Zod for request and response validation.

36. Avoiding Unsafe any

Avoid:

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

Prefer:

interface Data {
value: string;
}
function processData(data: Data): string {
return data.value;
}

For uncertain data, prefer unknown:

function processUnknown(data: unknown): void {
if (typeof data === "string") {
console.log(data.toUpperCase());
}
}

37. The never Type

never represents something that never returns normally.

function throwError(message: string): never {
throw new Error(message);
}

It can also help ensure every possible case is handled.

type Status = "pending" | "success" | "failed";
function assertNever(value: never): never {
throw new Error(
`Unhandled value: ${value}`
);
}
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Waiting";
case "success":
return "Completed";
case "failed":
return "Failed";
default:
return assertNever(status);
}
}

38. The satisfies Operator

satisfies checks that a value matches a type without losing its more specific inferred type.

type AppConfig = {
port: number;
environment:
| "development"
| "production";
};
const config = {
port: 3000,
environment: "development"
} satisfies AppConfig;

This is useful for configuration objects.

39. Mini Project — User Management System

type Role = "admin" | "editor" | "user";
interface User {
readonly id: number;
name: string;
email: string;
role: Role;
active: boolean;
}
type CreateUserInput = Omit<
User,
"id" | "active"
>;
type UpdateUserInput = Partial<
Omit<User, "id">
>;
class UserManager {
private users: User[] = [];
private nextId = 1;
create(input: CreateUserInput): User {
const user: User = {
id: this.nextId++,
active: true,
...input
};
this.users.push(user); return user;
}
getAll(): ReadonlyArray<User> {
return this.users;
}
findById(id: number): User | undefined {
return this.users.find(
(user) => user.id === id
);
}
update(
id: number,
updates: UpdateUserInput
): User {
const user = this.findById(id);
if (!user) {
throw new Error("User not found");
}
Object.assign(user, updates); return user;
}
delete(id: number): boolean {
const index = this.users.findIndex(
(user) => user.id === id
);
if (index === -1) {
return false;
}
this.users.splice(index, 1); return true;
}
}

Using the system:

const manager = new UserManager();
const user = manager.create({
name: "Ravi",
email: "ravi@example.com",
role: "admin"
});
console.log(user);manager.update(user.id, {
name: "Ravi Kumar"
});
console.log(manager.getAll());

This project combines:

  • Interfaces
  • Literal types
  • Classes
  • Private properties
  • Generics through utility types
  • Omit
  • Partial
  • ReadonlyArray
  • Error handling
  • Type-safe CRUD operations

TypeScript Best Practices

Enable strict mode

{
"compilerOptions": {
"strict": true
}
}

Prefer unknown over any

let data: unknown;

Use inference for obvious local values

const port = 3000;

You usually don’t need:

const port: number = 3000;

Type function boundaries

function calculateTotal(
price: number,
quantity: number
): number {
return price * quantity;
}

Use literal unions instead of unrestricted strings

Prefer:

type Role = "admin" | "user";

Instead of:

let role: string;

Keep types close to the related feature

Example:

users/
├── user.types.ts
├── user.service.ts
├── user.controller.ts
└── user.routes.ts

Do not trust external data automatically

API data, user input and environment variables require runtime validation.

TypeScript checks code during development, but it does not automatically validate data while the application is running.

Common Beginner Mistakes

Using any to fix every error

const data: any = response;

This removes TypeScript’s safety.

Overusing type assertions

const user = data as User;

This does not prove that data is a valid user.

Creating overly complex types too early

Start with clear interfaces and simple unions. Add advanced types only when they improve reuse or safety.

Forgetting that TypeScript becomes JavaScript

Interfaces, type aliases and type annotations do not exist in the compiled JavaScript.

Important Concepts Learned Today

✅ Advanced unions
✅ Discriminated unions
✅ Intersection types
✅ Type guards
✅ Custom type guards
✅ Generics
✅ Generic constraints
✅ keyof
✅ Indexed access types
✅ Classes
✅ Access modifiers
✅ Inheritance
✅ Abstract classes
✅ Interface implementation
✅ Utility types
✅ Safe error handling
✅ Typed asynchronous code
✅ Generic API responses
✅ never
✅ satisfies
✅ TypeScript best practices

Homework

Create:

  1. Generic function that returns the last array item
  2. Generic API response interface
  3. Role permission system using Record
  4. Product update function using Partial
  5. Public user type using Omit
  6. Class-based bank account system
  7. Typed CRUD service for products
  8. Custom type guard for API data

Practice Challenge

Build a type-safe product manager with:

createProduct()
getProducts()
findProductById()
updateProduct()
deleteProduct()

Use:

  • Interface
  • Class
  • Private array
  • Omit
  • Partial
  • ReadonlyArray
  • Error handling

Day 9

Node.js + TypeScript Setup

From Day 9 onward, the backend will use TypeScript only.

We’ll cover:

  • Node.js architecture
  • Project initialization
  • TypeScript configuration for Node.js
  • tsx or development runner setup
  • npm scripts
  • Node.js type definitions
  • Environment variables
  • ES modules
  • Folder structure
  • Git and .gitignore
  • Development and production builds

You’ll create the foundation used by every upcoming backend project.

--

--

Bhaskar Chowdary
Bhaskar Chowdary

Written by Bhaskar Chowdary

I believe in There is No Problem Without Solution