Skip to main content

Command Palette

Search for a command to run...

🔥TypeScript Handbook

Updated
90 min read
🔥TypeScript Handbook
T

I am a developer creating open-source projects and writing about web development, side projects, and productivity.

TypeScript is a widely used, open-source programming language that is perfect for modern development. With its advanced type system, TypeScript allows developers to write more robust, maintainable, and scalable code. But, to truly harness the power of TypeScript and build high-quality projects, it’s essential to understand and follow best practices. This article will walk through the Advanced Typescript concepts and capabilities. Whether you are just starting or you are an experienced Typescript developer, this article will provide valuable insights and tips to help you write clean, efficient code.

So, grab a cup of coffee, and let’s get started on our journey to mastering TypeScript!

Advanced Typescript cheat sheet

TypeScript is a simple language that allows developers to express types in terms of other types. However, while performing the basic tasks, having a profound understanding of how TypeScript works is critical for unlocking its advanced functionality.

As we learn more about TypeScript, we can utilize this knowledge to write cleaner and testable code. In this section, we are combining all the basic concepts of TypeScript and its advanced features in a single cheatsheet. So here we go.

TypeScript Concepts Misunderstood

Types vs interfaces in TypeScript

We have two options for defining types in TypeScript: types and interfaces. One of the most frequently asked questions about whether we should use interfaces or types.

The answer to this question, like many programming questions, is that it depends. In some cases, one has a clear advantage over the other, but in many cases, they are interchangeable.

Types and type aliases

type is a keyword in TypeScript that we can use to define the shape of data. The basic types in TypeScript include:

  • String

  • Boolean

  • Number

  • Array

  • Tuple

  • Enum

  • Advanced types

Each has unique features and purposes, allowing developers to choose the appropriate one for their particular use case.

Type aliases in TypeScript mean “a name for any type“. They provide a way of creating new names for existing types. Type aliases don’t define new types; instead, they provide an alternative name for an existing type.

Type aliases can be created using the type keyword, referring to any valid TypeScript type, including primitive types:

type MyNumber = number;
type User = {
  id: number;
  name: string;
  email: string;
}

Interfaces in TypeScript

In Typescript, an interface defines a contract to which an object must adhere. Below is an example:

interface Client { 
    name: string; 
    address: string;
}

Differences between types and interfaces

  • Primitive Types: Primitive types are built-in types in Typescript. They include number, string, boolean, null, and undefined types. We can define a type for a primitive type, but we can’t use an interface to alias a primitive type.

      type Address = string;
      type NullOrUndefined = null | undefined;
    
  • Union types: Union types allow us to describe values that can be one of several types and create unions of various primitive, literal, or complex types. Union types can only be defined using type. There is no equivalent to a union type in an interface. But it’s possible to create a new union type from two interfaces.

      type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk';
      interface CarBattery {
        power: number;
      }
      interface Engine {
        type: string;
      }
      type HybridCar = Engine | CarBattery;
    
  • Function types: In TypeScript, a function type represents a function’s type signature. Using a type alias, we need to specify the parameters and the return type to define a function type:

      type AddFn =  (num1: number, num2:number) => number;
      interface IAdd {
         (num1: number, num2:number): number;
      }
    

    Both type and interface similarly define function types, except for a subtle syntax difference of interface using “:” vs “=>” when using type. Type is preferred because it’s short and thus easier to read.

  • Declaration merging: Declaration merging is a feature that is exclusive to interfaces. With declaration merging, we can define interfaces multiple times, and the TypeScript compiler will automatically merge these definitions into a single interface definition.

    In the following example, the two Client interface definitions are merged into one by the TypeScript compiler, and we have two properties when using the Client Interface.

      interface Client { 
          name: string; 
      }
    
      interface Client {
          age: number;
      }
    
      const harry: Client = {
          name: 'Harry',
          age: 41
      }
    

    Type aliases can’t be merged in the same way if you try to define the Client type more than once, as, in the above example, the error will be thrown.

    When used in the right places, definition merging can be very useful. One common use case for declaration merging is to extend the third-party type definition to fit the particular project's needs.

    If you need to merge declarations, interfaces are the way to go.

  • Extends vs. intersection: An interface can extend one or multiple interfaces. Using the extend keyword, an interface can inherit all the properties and methods of an existing interface while also adding new properties.

    For example, we can create a VipClient interface by extending the Client interface:

      interface VIPClient extends Client {
          benefits: string[]
      }
    

    To achieve a similar result for types, we need to use an intersection operator:

      type VIPClient = Client & {benefits: string[]}; // Client is a type
    

    You can extend an interface from a type alias with static known members:

      type Client = {
          name: string;
      };
    
      interface VIPClient extends Client {
          benefits: string[]
      }
    
  • Handling conflicts when extending: Another difference between types and interfaces is how conflicts are handled when you try to extend from one with the same property.

    When extending interfaces, the same property isn’t allowed, as in the example below:

      interface Person {
        getPermission: () => string;
      }
    
      interface Staff extends Person {
         getPermission: () => string[];
      }
    

    An error is thrown because a conflict is detected.

    Type aliases handle conflicts differently. In the case of type aliases extending another type with the same property key, it will automatically merge all properties instead of throwing an error.

    In the following example, the intersection operator merges the method signatures of the two getPermission declarations and a typeof operator is used to narrow down the union type parameter so that we can get the return value in a type-safe way:

      type Person = {
        getPermission: (id: string) => string;
      };
    
      type Staff = Person & {
         getPermission: (id: string[]) => string[];
      };
    
      const AdminStaff: Staff = {
        getPermission: (id: string | string[]) =>{
          return (typeof id === 'string'?  'admin' : ['admin']) as string[] & string;
        }
      }
    

    It is important to note that the type intersection of two properties may produce unexpected results. In the example below, the name property for the extended type Staff becomes never, since it can’t be both number and string at the same time:

      type Person = {
          name: string
      };
    
      type Staff = person & {
          name: number
      };
      // error: Type 'string' is not assignable to type 'never'.(2322)
      const Harry: Staff = { name: 'Harry' };
    
  • Prefer extends over an intersection: Often, when using an interface, TypeScript will generally do a better job displaying the shape of the interface in error messages, tooltips, and IDEs. It is also much easier to read, no matter how many types you combine or extend.

    Compare that to the type alias that uses the intersection of two or more types like type A = B & C;, and you then type to use that alias in another intersection like type X = A & D;, TypeScript can struggle to display the structure of the combined type, making it harder to understand the shape of the type from the error messages.

    TypeScript caches the results of the evaluated relationship between interfaces, like whether one interface extends another or if two interfaces are compatible. This approach improves the overall performance when the same relationship is referenced in the future.

    In contrast, when working with intersections, TypeScript does not cache these relationships. Every time a type intersection is used, TypeScript has to re-evaluate the entire intersection, which can lead to efficiency concerns.

    For these reasons, it is advisable to use interface extends instead of relying on type intersections.

  • Advanced type features: TypeScript provides a wide range of advanced type features that can’t be found in interfaces. Some of the unique features in TypeScript include:

    • Type inferences: Can infer the type of variables and functions based on their usage. This reduces the amount of code and improves readability

    • Conditional types: Allow us to create complex type expressions with conditional behaviors that depend on other types

    • Type guards: Used to write sophisticated control flow based on the type of variable.

    • Mapped types: Transforms an existing object type into a new type

    • Utility types: A set of out-of-the-box utilities that help to manipulate types

TypeScript’s typing system constantly evolves with every new release, making it a complex and powerful toolbox. The impressive typing system is one of the main reasons many developers prefer to use TypeScript.

When to use types vs. interfaces

In many cases, they can be used interchangeably depending on personal preference. But we should use type aliases in the following use cases:

  • To create a new name for a primitive type

  • To define a union type, a tuple type, a function type, or another more complex type.

  • To overload functions.

  • To use mapped types, conditional types, type guards, or other advanced type features.

Compared with interfaces, types are more expressive. Many advanced type features are unavailable in interfaces, and those features continue to grow as TypeScript is involved.

Type safety in TypeScript - Unknown vs any

In Typescript, two types that are often used are any vs unknown. They might look the same at first, but they are used for different things.

Exploring unknown

The unknow type is a safer choice than any. When a variable is of type unknown, TypeScript makes you check the type before you can use the variable. This makes developers clearly state their assumptions, which helps to avoid errors when the code is running.

Think about a situation when you are using the data from an API, and you are not sure what the data looks like. Using an unknown type helps you deal with uncertainty in a safe way.

function processData(data: unknown): string {
  if (typeof data === 'string') {
    return data.toUpperCase();
  } else {
    // Handle other cases appropriately
    return "Invalid data";
  }
}

// No compilation error
const result = processData("Hello, TypeScript!");

In simpler terms, using unknown type ensures you double-check what kind of data you have before you do anything with it. This helps prevent mistakes that could happen when the program is running.

Exploring any

On the other hand, any is the most easy-going type in Typescript. It skips type-checking, letting variables of this type be set to anything without making the code fail to compile. While this freedom can be handy, it means you lose the advantages of static typing.

Think about a situation where you want as much flexibility as possible, and you are sure that your code is type-safe.

function processDynamicData(data: any): string {
  // No compilation error
  return data.toUpperCase(); 
}

// No compilation error, works as expected
const result1 = processDynamicData("Hello, TypeScript!"); 
console.log(result1); // Outputs: "HELLO, TYPESCRIPT!"

// No compilation error, but will cause a runtime error
const result2 = processDynamicData(12345); 
console.log(result2); // Error: data.toUpperCase is not a function

In simpler terms, using any means, you don’t have to check the type of data, but it can cause problems if the data type is not what you expected.

When to use

When using TypeScript, you can use unknown when you want to be very careful with your code and make sure everything is the right type. any is used when you want to be more flexible, but this can make your code less safe. You should think carefully about which one to use, depending on what your code needs and how confident you are about the types and safety of your code.

The Differences Between Object, {}, and object in Typescript

In Typescript, when we want to define an object type, there are several concise ways, such as Object, {}, and object. What are the differences between them?

Object (uppercased)

Object (uppercased) describes properties common to all JavaScript objects. It is defined in the lib.es5.d.ts file that comes with the TypeScript library.

As you can see, it includes some common properties like toString(), valueOf(), and so on.

Because it emphasizes only those properties that are common to JavaScript objects. So you can assign boxable objects like string, boolean, number, bigint, symbol to it, but not the other way around.

{}

{} describes an object that has no members on its own, which means Typescript will complain if you try to access its property members.

From the code example above, we can see that {} and Object (uppercased) have the same features. That is, it can only access those properties that are common (even if the JavaScript code logic is correct), and all boxable objects can be assigned to it, etc.

This is because the {} type can access those common properties through the prototype chain, it also has no own properties. So it behaves the same as the Object (uppercased) type. But they represent different concepts.

object (lowercased)

object (lowercased) means any no-primitive type, which is expressed in code like this:

type PrimitiveType =
  | undefined
  | null
  | string
  | number
  | boolean
  | bigint
  | symbol;

type NonPrimitiveType = object;

This means that all primitive types are not assignable to it, and vice versa:

TypeScript Advanced Concepts

Leverage Strict Typing Options

TypeScript’s compiler options allow you to enforce stricter type-checking rules. Setting "strict": true in your tsconfig.json is a great starting point, but consider enabling additional options like:

  • "noImplicitAny": Avoids using the any type unintentionally.

  • "strictNullChecks": Ensures variables cannot be null or undefined unless explicitly allowed.

  • "strictFunctionTypes": Enforces correct function type inference, preventing subtle bugs.

Stricter typing often reveals hidden bugs and makes your codebase more reliable.

Using never type

In Typescript, never is a special type that represents the value that will never occur. It’s used to indicate that a function will not return normally, but will instead throw an error. This is a great way to identify to other developers and the compiler that a function can be used in certain ways, which can help to catch potential bugs.

For example, consider the following function that throws an error if the input is 0:

function divide(numerator: number, denominator: number): number {
 if (denominator === 0) {
 throw new Error("Cannot divide by zero");
 }
 return numerator / denominator;
}

Here, the function divide is declared to return a number, but if the denominator is zero, it will throw an error. To indicate that this function will not return normally in this case, you can use never as a return type:

function divide(numerator: number, denominator: number): number | never {
 if (denominator === 0) {
   throw new Error("Cannot divide by zero");
 }
   return numerator / denominator;
}

Using Enums

Enums, short for enumerations, are a way to define a set of named constants in Typescript. They can be used to create a more readable and maintainable code by giving a meaningful name to a set of related values.

For example, you can use an enum to define a set of possible status values for an order:

enum OrderStatus {
 Pending,
 Processing,
 Shipped,
 Delivered,
 Cancelled
}

let orderStatus: OrderStatus = OrderStatus.Pending;

Enums can also have a custom set of numeric values or strings:

enum OrderStatus {
 Pending = 1,
 Processing = 2,
 Shipped = 3,
 Delivered = 4,
 Cancelled = 5
}

let orderStatus: OrderStatus = OrderStatus.Pending;

Always name an enum with the first capital letter, and the name has to be in the singular form, as part of the naming convention.

Read this article to explore the cases of TypeScript enums that are used in the real world.

Using Namespaces

Namespaces are a way to organize your code and prevent naming collisions. They allow you to create a container for your code, where you can define variables, classes, functions, and interfaces.

For example, you can use a namespace to group all the code related to a specific feature:

namespace OrderModule {
 export class Order { /* … */ }
 export function cancelOrder(order: Order) { /* … */ }
 export function processOrder(order: Order) { /* … */ }
}
let order = new OrderModule.Order();
OrderModule.cancelOrder(order);

You can also use namespaces to prevent naming collisions by providing a unique name for your code:

namespace MyCompany.MyModule {
 export class MyClass { /* … */ }
}

let myClass = new MyCompany.MyModule.MyClass();

It’s important to note that namespaces are similar to modules, but they are used to organize the code and prevent naming collisions, while modules are used to load and execute the code.

Using Typescript infer Like a Pro

Do you know how to get the type of the elements in the T0 array type and the return value type in the T1 function type? Give yourself a few seconds to think about it.

type T0 = string[];
type T1 = () => string;

We can use the type pattern matching technology provided by Typescript - conditional types and infer to complete the previous requirements.

Conditional types allow us to detect the relationship between 2 types and determine whether they are compatible. Infer is used to declare a type variable to store the type captured during pattern matching.

Let’s see how to capture the type of the elements in the T0 array type:

type UnpackedArray<T> = T extends (infer U)[] ? U : T
type U0 = UnpackedArray<T0> // string

In the above code, T extends (infer U)[] ? U : T is the syntax for conditional types, infer U in the extended clauses, introduce a new type variable U to store the inferred type.

For a better understanding, let’s demonstrate the execution flow of the UnpackedArray utility type.

It should be noted that infer can only be used in the extends clause of the conditional type, and the type variable declared by infer is only available in the true branch of the conditional type.

type Wrong1<T extends (infer U)[]> = T[0] // Error
type Wrong2<T> = (infer U)[] extends T ? U : T // Error
type Wrong3<T> = T extends (infer U)[] ? T : U // Error

For more use cases and more explanations, refer to this article.

Using Typescript Conditional Types Like a Pro

Have you used the Exclude, Extract, NonNullable, Parameters, and ReturnType utility types? Do you know how they work internally? In fact, the above Typescript built-in utility types are all developed based on Conditional Types.

Here, we first briefly understand the specific implementation of these TypeScript built-in utility types.

These utility types are used for the following purposes:

  • Exclude: Constructs a type by excluding it from UnionType all union members who are assigned to ExcludedMembers.

  • Extract: Constructs a type by extracting from Type all union members who are assigned to Union.

  • NonNullable: Constructs a type by excluding null and undefined from Type.

  • Parameters: Constructs a tuple type from the types used in the parameters of a function type Type.

  • ReturnType: Constructs a type consisting of the return type of the function Type.

Here we look at a few usage examples:

If you want to master them thoroughly and create your own utility types.

The built-in utility types described earlier use the Conditional Types introduced in Typescript 2.8 internally. The syntax for this type is as follows:

T extends U ? X : Y

So what are the uses of conditional types? Let’s take an example here:

type IsString<T> = T extends string ? true : false;
​
type I0 = IsString<number>;  // false
type I1 = IsString<"abc">;  // true
type I2 = IsString<any>;  // boolean
type I3 = IsString<never>;  // never

Refer this article to explore more.

What are K, T, and V in Typescript Generics?

Does it sound strange when you first see the T in Typescript generics?

The T in the figure is called a generic type parameter, and it is a type placeholder we wish to pass the identity function.

Just like passing parameters, we take the actual type specified by the user and chain it to the parameter type and return the value type.

So what does T mean? The generic type parameter T in the figure represents Type; in fact, T can be replaced by any valid name. In addition to T, common generic variables are K, V, E, etc.

  • K(Key): represents a type of key in an object.

  • V(Value): represents a type of value in an object.

  • E(Element): represents the element type.

Of course, you don’t have to define only one type parameter; you can introduce any number of type parameters. Here, we introduce a new parameter type U that extends the identity function we defined.

When calling the identity function, we can explicitly specify the actual type of the generic parameter. Of course, you can also not specify the type of the generic parameter, and let Typescript automatically complete the type reference for us.

https://medium.com/web-tech-journals/think-you-know-generics-these-typescript-tricks-will-surprise-you-ea4ee22129a2

Using Typescript Mapped Types Like a Pro

Have you used the Partial, Required, Readonly, and Pick utility types?

Do you know how they work internally? If you want to master them thoroughly and create your own utility types, don’t miss the content below :)

User Registration is a widespread scenario in daily work. Here we can use Typescript to define a User in which all keys are required.

type User = {
  name: string; 
  password: string; 
  address: string; 
  phone: string;
};

Usually, for registered users, we allow users to modify only some user information. At this point, we can define a new UserPartial type representing the object type to update, in which all keys are optional.

type UserPartial = {
  name?: string; 
  password?: string; 
  address?: string; 
  phone?: string; 
};

For the scenario of viewing user information, we hope that all keys of the object type corresponding to the user object are read-only. For this requirement, we can define the Readonly User type.

type ReadonlyUser = {
  readonly name: string;
  readonly password: string;
  readonly address: string;
  readonly phone: string;
};

Reviewing the 3 user-related types already defined, we will see that they contain a lot of duplicate code.

So, how can we reduce the duplicate code in the above types? The answer is we can use the mapped type, which is a generic type that can be used to map the original object type to a new object type.

The syntax for mapped types is as follows:

Where P in K is similar to the JavaScript for … in the statement, which is used to iterate through all types in type K, and the T type variable, which is used to represent any type in Typescript.

You can also use the additional modifiers read-only and question mark (?) in the mapping process. The corresponding modifiers are added and removed by adding the plus(+) and minus(-) prefixes. The default is to use the plus sign if no prefix is added.

We can now summarize the syntax of the common mapped type.

{ [ P in K ] : T }
{ [ P in K ] ?: T }
{ [ P in K ] -?: T }
{ readonly [ P in K ] : T }
{ readonly [ P in K ] ?: T }
{ -readonly [ P in K ] ?: T }

After introducing the syntax of common mapped types, let’s look at some examples:

Let’s look at how to redefine the PartialUser type using mapped types:

type MyPartial<T> = {
  [P in keyof T]?: T[P];
};
type UserPartial = MyPartial<User>;

In the above code, we define the MyPartial type-mapped type and then use it to map the User type to the UserPartial type. The keyof operator is used to get all the keys of a type, and its return type is a union type. The type variable P changes to a different type with each traversal, T[P], which is similar to the syntax for attribute access, and is used to get the type of the value corresponding to an attribute of the object type.

Let’s demonstrate the complete execution flow of MyPartial mapped types. If you are not sure, you can watch it several times to deepen your understanding of the TypeScript mapped type.

Refer to this article to explore more.

Using Typescript Template Literal Types Like a Pro

When developing web pages, we usually use a Tooltip or a Popover to display prompt messages or explanatory information. In order to meet some usage scenarios, Tooltop or Popover will allow the user to set their placement position. For example, top, bottom, left, right, etc.

Since string literal types can basically spell-check our string values, we define a Side type using Typescript’s type aliases.

type Side = 'top' | 'right' | 'bottom' | 'left';
let side: Side = "rigth"; // Error
// Type '"rigth"' is not assignable to type 'Side'. 
// Did you mean '"right"'?ts(2820)

For the above 4 positions, it can already meet most scenarios. But if you want to set the placement position of the Tooltip more precisely, for example, let the Tooltip display in the upper area of the specified elements:

Then the existing Side can’t meet the requirements, let’s define the new Placement type:

type Placement = Side
  | "left-start" | "left-end" 
  | "right-start" | "right-end" 
  | "top-start" | "top-end" 
  | "bottom-start" | "bottom-end"

In the Placement type, in addition to the original 4 positions, we add 8 new positions, such as “left-start“, “left-end“, and “right-start“, which correspond to the 8 string literal types. Looking at these string literal types, we find some duplicate code “-start“ and “-end“. In addition, when defining these literal types, spelling errors may occur if you are not careful.

So, how can the above problems be solved better? This is where we can use the new template literal types introduced in TypeScript 4.1, which are used in the following way:

type Alignment = 'start' | 'end';
type Side = 'top' | 'right' | 'bottom' | 'left';
type AlignedPlacement = `${Side}-${Alignment}`;
type Placement = Side | AlignedPlacement;

After reading the above code, do you think it is much simpler? Similar to template strings in Typescript, template literal types are enclosed in backticks and can contain placeholders of the form ${T}. The actual type of the type variable T can be string, number, boolean, or bigint.

Template literal types provide us with the ability to concatenate string literals and convert literals of non-string primitive types to their corresponding string literal types. Here are some examples:

Refer to this article to explore more.

The Purpose of declare Keyword in Typescript

When you open the *.d.ts file in TypeScript projects, you may see delcare. Do you know what delcare does?

In Typescript projects, you may import third-party JS-SDK in the form of script tags, such as importing the JS-SDK of the Google Maps platform.

<script
   src="https://maps.googleapis.com/maps/api/js?key=AIzaSyB41DRUbKWJHPxaFjMAwdrzWzbVKartNGg&callback=initMap&v=weekly" defer></script>

After initialization, you will call the API exposed by the JS-SDK in a Typescript file.

Although you are using the API provided by JS-SDK according to the Google Maps development documentation, the Typescript compiler still prompts the corresponding error message for the above code. This is because the TypeScript compiler does not recognize the global variable google.

So how do we solve this problem? The answer is to use the declare keyword to declare the global google variable so that the Typescript compiler can recognize the global variable.

declare var google: any;

Seeing this, do you get confused? Why can you use global variables like JSON, Math, or Object in Typescript projects, normally? This is because Typescript makes the declaration for us internally, and the global variables mentioned earlier are declared in the lib.es5.d.ts declaration file.

// typescript/lib/lib.es5.d.ts
declare var JSON: JSON;
declare var Math: Math;
declare var Object: ObjectConstructor;

In fact, in addition to declaring global variables, the declare keyword can also be used to declare global functions, global classes, and global enum types. Functions such as eval, isNaN, encodeURI, and parseInt that you may have used at work, also declare in the lib.es5.d.ts declaration file.

declare function eval(x: string): any;
declare function isNaN(number: number): boolean;
declare function encodeURI(uri: string): string;
declare function parseInt(string: string, radix?: number): number;

It should be noted that when declaring a global function, we do not include the specific implementation of the function. With the declaration file, TypeScript can recognize the global JavaScript functions.

Explore more here.

How To Define Object Type With Unknown Structures in Typescript

Did you encounter similar errors when you were learning Typescript?

To fix this error, a very violent way is to use any type:

let user: any = {}
user.id = "TS001";
user.name = "Bytefer";

Beside using any type, how many solutions do you know? In this article, I will introduce 3 other solutions. Before you continue reading, I suggest you take a moment to think about it.

One of the solutions is to use type or interface to define a User type:

interface User {
  id: string;
  name: string;
}
let user = {} as User;
user.id = "TS001";
user.name = "Bytefer";

Although using the User type, the previous problem can be solved. But you set a new age property for the user object, the following error message will be displayed:

Property 'age' does not exist on type 'User'.ts(2339)

So, how should we solve the problem of dynamic property assignment? At this point, we can use TypeScript’s index signatures. When we only know the type of the object keys and values, we can use index signatures to define the type of that object. The syntax of the index signatures is as follows:

The type of the key can only be string, number, symbol, or template literal type, while the type of the value can be any type.

The template literal type is a new type introduced in TypeScript 4.1, and in combination with index signatures, we can define more powerful types.

In addition to using index signatures, we can also use TypeScript’s built-in utility type Record type to define the User type. The role of the Record utility type is as follows:

So what’s the difference between index signatures and a Record utility type? In some cases, they all define the expected type.

const user1: Record<string, string> = { name: "Bytefer" }; // Ok
const user2: { [key: string]: string } = { name: "Bytefer" }; // Ok

For index signatures, the key type can only be string, number, symbol, or template literal type. For the Record utility type, the key type can be a literal type or a union of literal types:

Using Typescript Type Predicates for Precise Type Checking

Type predicates are a powerful tool used to check and ensure at runtime that a variable belongs to a specific type. By using type predicates, we can achieve more precise type checking when writing type-safe code, thereby avoiding type errors and enhancing the robustness and maintainability of the code.

Suppose we have a union type representing animals, including cats(Cat) and dogs (Dog).

interface Cat {
  kind: "cat";
  meow: () => void;
}

interface Dog {
  kind: "dog";
  bark: () => void;
}

type Animal = Cat | Dog;

Now, we want to write a function to check whether an Animal is of type Cat. At this point, we can use the type predicate:

function isCat(animal: Animal): animal is Cat {
  return animal.kind === "cat";
}

function makeSound(animal: Animal) {
  if (isCat(animal)) {
    animal.meow(); // Here, TypeScript knows that animal is of type Cat.
  } else {
    animal.bark(); // Here, TypeScript knows that animal is of type Dog.
  }
}

In the example above, we define a function name isCat and use the type predicate animal is Cat as the return type. The function checks if the kind property of the passed animal object is equal to “cat”. If it is, the function returns true and informs the Typescript compiler that, within this conditional branch, the animal variable is of type Cat.

By doing this, the makeSound function can accurately identify the specific type of Animal and call the methods specific to Cat or Dog in the corresponding conditional branches. This not only enhances the type safety of the code but also makes the code cleaner and easier to maintain.

Using Typescript Recursive Like a Pro

A linked list is a common data structure that contains a series of nodes, each node contains two parts: data and a pointer to the next node. The first node in the linked list is called the head node, and the last node is called the tail node.

So, how do we define a linked table type in Typescript? If you want to use an type or interface to define a linked table type, we need to use the recursive type in Typescript. A recursive type is a type that can refer to itself. This type is useful when dealing with recursive structures because it allows us to define a type that can refer to itself to represent each node of a recursive structure.

Let’s take a look at how to use recursive types to define the type of a singly linked list:

interface ListNode<T> {
  data: T;
  next: ListNode<T> | null;
}

interface LinkedList<T> {
  head: ListNode<T> | null;
  tail: ListNode<T> | null;
  length: number;
  get(index: number): ListNode<T> | null;
  insert(index: number, value: T): boolean;
  remove(index: number): ListNode<T> | null;
}

In the above code, ListNode<T> represents the type of the chain node, and contains two properties, data and next, where the data property is used to store the value of the node, and the next property is used to store the pointer to the next node. Note that when the next property is not empty, it is of type ListNode<T>, which uses Typescript’s recursive types. And LinkedList<T> represents the type of a singly linked list, which contains the head node and the tail node, as well as the length property and some methods to manipulate the list.

For more details, refer to this article.

Use ReturnType and Awaited

  • To get the return type of the function, use ReturnType.

  • To get the return type of an async function, wrap it in Awaited.

export async function loader() {
  return {…}
}
type LoaderData = Awaited<ReturnType<typeof loader>>

Object-Oriented Programming in TypeScript

Object-Oriented Programming (OOP) is one of the most widely used programming paradigms in software development. But it’s also one of the most understood.

This section will help you gain a solid grasp of OOP in Typescript by walking you through the language features that support it, and then showing how these features naturally give rise to the four foundation principles: inheritance, polymorphism, encapsulation, and abstraction.

TypeScript Language Features

In this section, we will explore TypeScript’s features that facilitate OOP implementation. Similar mechanisms exist in other object-oriented languages, such as Java or C#, though they may vary in syntax while preserving the core concepts.

  1. Objects

An object is a data type that stores a collection of values organized into key/value pairs. These may include primitive data or other objects.

In the following example, the person object stores various pieces of information, such as the key name, which contains the value "Lucas" of type string, and the address key, which holds another object.

const person = {
  name: "Lucas", // primitive value of type string
  surname: "Garcez",
  age: 28, // primitive value of type number
  address: {
    // object type containing the keys "city" and "country"
    city: "Melbourne",
    country: "Australia",
  },
};
  1. Classes, Attributes, and Methods

A class serves as a blueprint for creating objects. It specifies an object’s structure and behavior through its attributes and methods. Attributes outline the data structure (keys and value types), whereas methods define the actions that can be performed on those attributes.

class Person {
  name: string; // attribute
  surname: string; // attribute
  age: number; // attribute

  // constructor method (special method)
  constructor(name: string, surname: string, age: number) {
    this.name = name;
    this.surname = surname;
    this.age = age;
  }

  // method to obtain the full name: "Lucas Garcez"
  getFullName() {
    return `${this.name} ${this.surname}`;
  }
}
  1. Constructor Method

This constructor is a special method within a class. It’s automatically invoked when a new object is created. Constructors are responsible for initializing the class attributes with values provided during object creation.

In TypeScript, the constructor is defined using the constructor keyword, as you can see in the example above.

  1. Instance

An instance refers to an object created from a class. For example, using the class Person mentioned above, you can create an object named lucas. Therefore, lucas is an instance of the class Person. To create an instance of an object in JavaScript or TypeScript, you use the keyword new, as demonstrated below:

const lucas = new Person("Lucas", "Garcez", 28);
lucas.name; // "Lucas"
lucas.getFullName(); // "Lucas Garcez"

It’s important to note that you can create multiple objects (instances) from the same class. Although these objects share the same structure (attributes and methods), they are independent and occupy separate memory spaces within the program.

For instance, when creating a new object:

const maria = new Person("Maria", "Oliveira", 19);

You now have a new instance of the Person class that doesn't interfere with the previously created lucas object. Each instance maintains its own values and behaviors, ensuring that manipulating one object doesn’t affect to the others.

  1. Interfaces

An interface defines a contract establishing which attributes and methods a class must implement. In TypeScript, this relationship is established using the keyword implements. When a class implements an interface, it must include all the attributes and methods specified by that interface and their respective types.

In the following example, you have a banking system where a customer can have either CurrentAccount or SavingsAccount account. Both options must adhere to the bank’s general account rules defined by the BankAccount interface.

// Contract defining the attributes and methods of a bank account
interface BankAccount {
  balance: number;
  deposit(amount: number): void;
  withdraw(amount: number): void;
}

class CurrentAccount implements BankAccount {
  balance: number;
  // The class can have other attributes and methods
  // beyond those specified in the interface
  overdraftLimit: number;

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount <= this.balance) {
      this.balance -= amount;
    }
  }
}

class SavingsAccount implements BankAccount {
  balance: number;

  deposit(amount: number): void {
    // can have different logic from CurrentAccount
    // but must respect the method signature,
    // i.e., parameters (amount: number) and return type (void)
  }

  withdraw(amount: number): void {
    // ...
  }
}
  1. Abstract Classes

Just like interfaces, abstract classes define a model or contract that other classes must follow. But while interfaces only describe the structure of the class without providing implementations, an abstract class can include method declarations and concrete implementations.

Unlike regular classes, though, abstract classes can not be instantiated directly — they exist solely as a base from which other classes can inherit their methods or attributes.

In Typescript, the abstract keyword is used to define an abstract class. In the following example, you will refactor the banking system by replacing an interface with an abstract class to define behaviors for all bank accounts.

// Abstract class that serves as the base for any type of bank account
abstract class BankAccount {
  balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  // Concrete method (with implementation)
  deposit(amount: number): void {
    this.balance += amount;
  }

  // Abstract method (must be implemented by subclasses)
  abstract withdraw(amount: number): void;
}

class CurrentAccount extends BankAccount {
  withdraw(amount: number): void {
    const fee = 2; // Current accounts have a fixed withdrawal fee
    const totalAmount = amount + fee;

    if (this.balance >= totalAmount) {
      this.balance -= totalAmount;
    } else {
      console.log("Insufficient balance.");
    }
  }
}

class SavingsAccount extends BankAccount {
  withdraw(amount: number): void {
    if (this.balance >= amount) {
      this.balance -= amount;
    } else {
      console.log("Insufficient balance.");
    }
  }
}

// ❌ Error! Cannot instantiate an abstract class
const genericAccount = new BankAccount(1000); // Error

// ✅ Creating a current account
const currentAccount = new CurrentAccount(2000); // uses the BankAccount constructor
currentAccount.deposit(500); // uses the deposit method from BankAccount
currentAccount.withdraw(300); // uses the withdraw method from CurrentAccount

// ✅ Creating a savings account
const savingsAccount = new SavingsAccount(1500); // uses the BankAccount constructor
savingsAccount.deposit(1100); // uses the deposit method from BankAccount
savingsAccount.withdraw(500); // uses the withdraw method from SavingsAccount

Object-Oriented Programming Principles

Now that you understand the key language mechanisms, you can formalize the pillars of Object-Oriented Programming that guide the creation of systems that are better organized, reusable, and scalable.

  1. Inheritance — Superclass and Subclass

Inheritance is a mechanism that allows a class to derive characteristics from another class. When a class B inherits from a class A, it means that class B automatically acquires the attributes and methods of A without needing to redefine them.

You can visualize this relationship as a parent-child structure, when A is the superclass (parent/base class) and B is the subclass (derived/child class). A subclass can use inherited resources, add new behaviors, or override superclass methods to address specific needs.

We’ve already discussed inheritance when learning about abstract classes, but inheritance can also be applied to concrete classes. This allows for code reuse and behavior specialization.

// BankAccount is now a regular class where you define attributes and methods
// that will be reused by the child class CurrentAccount
class BankAccount {
  balance: number = 0;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount <= this.balance) {
      this.balance -= amount;
    }
  }
}

// CurrentAccount is a subclass of BankAccount, meaning 
// it inherits its attributes and methods.
class CurrentAccount extends BankAccount {
  overdraftLimit: number; // new attribute exclusive to CurrentAccount

  // When specifying a constructor method for a subclass,
  // we need to call another special method, "super".
  // This method calls the superclass (BankAccount) constructor to ensure
  // it is initialized before creating the CurrentAccount object itself.
  constructor(initialBalance: number, overdraftLimit: number) {
    super(initialBalance); // Must match the superclass constructor method signature
    this.overdraftLimit = overdraftLimit;
  }

  // Even though the withdraw method already exists in the superclass (BankAccount),
  // it is overridden here. This means every time a CurrentAccount
  // object calls the withdraw method, this implementation will be used, 
  // ignoring the superclass method.
  override withdraw(amount: number): void {
    const totalAvailable = this.balance + this.overdraftLimit;
    if (amount > 0 && amount <= totalAvailable) {
      this.balance -= amount;
    }
  }
}

// Creating a CurrentAccount with an initial balance of $0.00
// and an overdraft limit of $100.
const currentAccount = new CurrentAccount(0, 100);

// Making a $200 deposit by calling the deposit method
// In this case, the method from BankAccount will be invoked
// since deposit was not overridden in CurrentAccount
currentAccount.deposit(200); // balance: 200

// Withdrawing $250 by calling the withdraw method
// In this case, the method from CurrentAccount will be invoked
// as it has been overridden in its definition
currentAccount.withdraw(250); // balance: -50
  1. Polymorphism

Polymorphism is a concept that often creates confusion in Object-Oriented Programming. But in practice, it’s merely a natural consequence of using interfaces and inheritance.

The term polymorphism originates from Greek and means “many forms“ (poly = many, morphos = forms). This concept allows objects from different classes to respond to the same method call but with distinct implementations, making code more flexible and reusable.

To clarify this concept, let’s consider a practical example. Suppose you have a function named sendMoney, responsible for processing a financial transaction, transferring a certain amount from account A to account B. The only requirement is that both accounts follow a common contract, ensuring the methods withdraw and deposit are available.

// BankAccount could be an interface, a concrete class,
// or an abstract class. For the sendMoney function, the specific implementation
// does not matter—only that BankAccount includes withdraw and deposit methods.
function sendMoney(
  sender: BankAccount,
  receiver: BankAccount,
  amount: number
) {
  sender.withdraw(amount);
  receiver.deposit(amount);
}

const lucasAccount = new CurrentAccount(500, 200);
const mariaAccount = new SavingsAccount(300);

// transferring $100 from Lucas to Maria
sendMoney(lucasAccount, mariaAccount, 100);

Polymorphic Methods:

The withdraw and deposit methods are called within the sendMoney function without requiring the function to know whether it is dealing with a CurrentAccount or SavingsAccount. Each class implements withdraw according to its own rules, demonstrating the concept of polymorphism.

Decoupling:

The sendMoney function does not depend on the specific type of bank account. Any class that extends BankAccount (if it's a class) or implements BankAccount (if it's an interface) can be used without requiring modifications to the sendMoney function.

With this approach, you ensure flexibility and code reusability, as new account types can be introduced without affecting the functionality of sendMoney.

  1. Encapsulation

Encapsulation is one of the fundamental principles of OOP, and its concept can be applied to any programming paradigm. It involves hiding the internal implementation details of a module, a class, or a function, or any other software components, exposing only what is necessary for external use. This improves code security, maintainability, and modularity by preventing unauthorized access and ensuring controlled interactions.

Access Modifiers – public, private, and protected

In OOP, encapsulation is essential for controlling the visibility and access to methods and attributes within a class. In TypeScript, this is achieved using access modifiers, which are defined by the keywords public, protected, and private.

  • public – Allows the attribute or method to be accessed from anywhere, both inside and outside the class. This is the default visibility, meaning that if no access modifier is specified in the code, TypeScript assumes it as public.

  • protected – Allows access within the class and its subclasses but prevents external access.

  • private – Restricts access to the attribute or method only within the class itself.

export class Person {
  private firstName: string; // Accessible only within the class itself
  private lastName: string; // Accessible only within the class itself
  protected birthDate: Date; // Accessible by subclasses but not from outside

  constructor(firstName: string, lastName: string, birthDate: Date) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.birthDate = birthDate;
  }

  // Public method that can be accessed from anywhere
  public getFullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

// The Professor class inherits from Person and can access
// attributes and methods according to their access modifiers.
class Professor extends Person {
  constructor(firstName: string, lastName: string, birthDate: Date) {
    super(firstName, lastName, birthDate); // Calls the superclass (Person) constructor
  }

  getProfile() {
    this.birthDate; // ✅ Accessible because it is protected
    this.getFullName(); // ✅ Accessible because it is public
    this.firstName; // ❌ Error! Cannot be accessed because it is private in the Person class
    this.lastName; // ❌ Error! Cannot be accessed because it is private in the Person class
  }
}

function main() {
  // Creating an instance of Professor
  const lucas = new Professor("Lucas", "Garcez", new Date("1996-02-06"));

  // Testing direct access to attributes and methods
  lucas.birthDate; // ❌ Error! birthDate is protected and can only be accessed within the class or subclasses
  lucas.getFullName(); // ✅ Accessible because it is a public method
  lucas.firstName; // ❌ Error! firstName is private and cannot be accessed outside the Person class
  lucas.lastName; // ❌ Error! lastName is also private and inaccessible outside the Person class
}

Access Modifiers Table

ModifierAccess within the classAccess in subclassAccess outside the class
public✅ Yes✅ Yes✅ Yes
protected✅ Yes✅ Yes❌ No
private✅ Yes❌ No❌ No
  1. Abstraction

This concept of abstraction frequently confuses because its meaning goes beyond the technical context. If you look up the definition of the word in English, the Cambridge Dictionary defines "abstract" as:

Something that exists as an idea, feeling, or quality, rather than as a material object.

This definition can be directly applied to OOP: Abstraction represents an ideal or concept without going into concrete implementation details.

Many online references describe abstraction as “hiding implementation details“, which can be misleading since this concept is more closely related to encapsulation. In OOP, abstraction does not mean hiding details but defining constructs through abstract classes and interfaces.

// Abstraction using interface
interface BankAccountInterface {
  balance: number;
  deposit(amount: number): void;
  withdraw(amount: number): void;
}

// Abstraction using class
abstract class BankAccountClass {
  balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  // Concrete method (with implementation)
  deposit(amount: number): void {
    this.balance += amount;
  }

  // Abstract method (must be implemented by subclasses)
  abstract withdraw(amount: number): void;
}

In the examples above, both BankAccountInterface and BankAccountClass are examples of abstraction as they define contracts that must be implemented by those who use them.

Typescript Visualized: 15 Most Used Utility Types

In the process of using Typescript, we are programming type-oriented manner. In order to meet several work scenarios, we need to transform the known type. For the convenience of TypeScript users, the TypeScript team has provided us with many built-in utility types. With these utility types, we can easily convert types, extract types, exclude types, or get the parameter type or return type value of the function.

I picked 15 very useful utility built-in types and introduced their usage and internal working principles in the form of images or animations. After that, I believe you can really master the usage of these utility built-in types.

https://4markdown.com/5-game-changing-typescript-utility-types-you-should-master/

Partial<Type>

Constructs a type with all the properties of Type set to optional.

/**
 * Make all properties in T optional. 
 * typescript/lib/lib.es5.d.ts
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

Required<Type>

Constructs a type consisting of all properties of Type set to the required. The opposite of Partial.

/**
 * Make all properties in T required.
 * typescript/lib/lib.es5.d.ts
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};

For more utility types, explore here.

Typescript Index Signature Explained

An index signature is defined using square brackets [] and the type of keys, followed by a colon and the type for the corresponding values. It enables Typescript to understand and enforce the expected structure of the object.

interface MyStats {
  [key: string]: number;
}
const scores: MyStats = {
  total: 50,
  average:80
}
// index siganture enforce the type constraint
// here, the value must be a number
const scores2: MyStats = {
  total: "50", //Type 'string' is not assignable to type 'number'.(2322)
  average:80
}

Note that index signatures can use different key types, such as string, number, symbol, or literal type, and the associated value type can be any valid TypeScript type.

Mixing an index signature with explicit members

In Typescript, we can mix an index signature with explicit member declarations. It’s helpful for cases requiring a combination of known and dynamic properties.

interface CarConfiguration {
  [feature: string]: number;
  price: number;
}

When we mix index signatures with explicit members, all explicit members need to conform to the index signature types.

// invalid case
interface CarConfiguration {
  [feature: string]: number;
  price: number;
  model: string; // Error: Property 'model' of type 'string' is not assignable to 'string' index type 'number'
}

// valid
interface CarConfiguration {
  [feature: string]: number | string;
  price: number;
  model: string;
}

Read-only index signature

Index signature supports readonly modifier. By applying readonly modifier, the properties in the object will be immutable.

interface Car {
  readonly [key: string]: boolean;
}

const toyota: Car = {hybrid: true, luxury: false};
toyota.hybrid = false; //Error: Index signature in type 'Car' only permits reading.(2542)

In the above example, an error occurs when trying to modify hybrid property because the interface only allows for reading, not writing.

How to use an index signature

Let’s consider a real-world example of how index signatures can be used. Imagine you are developing an application with various features. Each feature includes its own set of settings, which can also be enabled or disabled.

interface FeatureConfig {
  [feature: string]: {
    enabled: boolean;
    settings: Record<string, boolean>;
  }
}

In this example, we define an interface named FeatureConfig. It uses an index signature to allow dynamic property names of type string associated with anenabled boolean property and a settings object. It is handy for representing configurations with dynamic feature names and associated settings. For example, we can apply the interface to the following object.

const features: FeatureConfig = {
  profile: {
    enabled: true,
    settings: {
      showPhoto: true,
      allowEdit: false,
    },
  },
  notification: {
    enabled: false,
    settings: {
      richText: true,
      batchMode: true
    },
  }
};

In the features object, the feature names can vary, but the structure for each feature remains consistent. Each feature is expected to have an enabled boolean and a settings object.

To improve the type safety, can we apply a union-type constraint to the feature name in the above interface?

If the set of features in our application is known, we can define the union of string literals namedFeatureType.

The key of the index signature does not support the union type, but we can work around it using a mapped type.

type FeatureType = 'profile' | 'notification' | 'reporting';

type FeatureConfig2 = {
  [feature in FeatureType]: {
    enabled: boolean;
    settings: Record<string, boolean>;
  }
}

[feature in FeatureType]is a mapped type that iterates over each string literal in the union type FeatureType (which includes 'profile', 'notification', and 'reporting'), and it uses each value as the resulting type's property name.

Here’s an example of how we might use it:

const allFeatures: FeatureConfig2 = {
  profile: {
    enabled: true,
    settings: {
      showPhoto: true,
      allowEdit: false,
    },
  },
  notification: {
    enabled: false,
    settings: {
      richText: true,
      batchMode: true
    },
  },
    reporting: {
    enabled: true,
    settings: {
      template: false,
      advanceExport: true
    },
  },
};

Note that we need to include all features defined in FeatureType to the object to match the type expectations.

If we want to allow a subset of the features as the key, we need to modify the index signature type with an “?” as an optional flag. Then, we could use the FeatureConfig2 type for an object that only contains a subset of features.

type FeatureType = 'profile' | 'notification' | 'reporting';

type FeatureConfig2 = {
  [feature in FeatureType]?: {
    enabled: boolean;
    settings: Record<string, boolean>;
  }
}

const subsetFeatures: FeatureConfig2 = {
  profile: {
    enabled: true,
    settings: {
      showPhoto: true,
      allowEdit: false,
    },
  }
};

Index signature and Record type

A common question about index signatures is “Why not use record type instead?“.

Record type defines an object type where we know the specific set of properties and their corresponding types. For example, below, the UserRole type restricts the keys to “admin”, “editor” or “viewer”.

type UserRole = "admin" | "editor" | "viewer";

type UserPermissions = Record<UserRole, boolean>;

const permissions: UserPermissions = {
  admin: true,
  editor: false,
  viewer: true,
};

For the index signature, we don’t know the specific properties; we only know the general type of property types. Thus, the use case of the index signature is to model dynamic objects, and the record type can be used for known object types.

Typescript Type Guards

A type guard is an expression that performs a runtime check that guarantees the type in some scope. A typical application scenario for type guarding is to narrow the type scope of a union type. This is to ensure type safety, that is, to safely access specific properties or methods in a particular type of object during runtime.

typeof type guards

First, let’s introduce the more common typeof type of guards. The typeof operator can obtain the type of an object at runtime, and the operator returns the following possible values.

  • "string"

  • "number"

  • "bigint"

  • "boolean"

  • "symbol"

  • "undefined"

  • "object"

  • "function"

So using the typeof operator, we can get the actual value of a variable at runtime. Let’s take an example:

function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(`ID: ${id.toUpperCase()}`);
  } else if (typeof id === "number") {
    console.log(`ID: ${id}`);
  }
}

So why use the typeof operator to narrow down the type of the id parameter? The main reason is to ensure type safety at runtime. For example, when the type of ID parameter is a numeric type, but we call the id.toUpperCase() method, a runtime exception will be thrown.

In editors that support Typescript IntelliSense, you can access certain properties of the id parameter, and you can access common properties of the string and number types. Specifically, as shown in the picture below:

instanceof type guards

Although the typeof operator can distinguish different types, if we want to determine whether an object is an instance of a certain class, so as to safely access the unique properties or methods on the instance, then the typeof operator can do nothing. For this requirement, we can use instanceof operator. Again, let’s take a concrete example:

class Shape {
  constructor(public id: string) {}
}

class Circle extends Shape {
  constructor(
    public id: string, 
    public radius: number) {
   super(id);
  }
}

class Square extends Shape {
  constructor(
    public id: string, 
    public sideLength: number) {
      super(id);
  }
}

In the above code, we define a Shape class and create two subclasses based on it. Next, we define a printShapeInfo function to print information about different shapes:

function printShapeInfo(shape: Shape) {
  if (shape instanceof Circle) {
    console.log(`Circle's radius is: ${shape.radius}`);
  } else if (shape instanceof Square) {
    console.log(`Square's sideLength is: ${shape.sideLength}`);
  }
}

in type guards

For the previous example of using the instanceof operator to implement type guards, we can also use the form of interfaces to describe the Shape, Circle and Square types.

interface Shape {
  id: string;
}

interface Circle extends Shape {
  radius: number;
}

interface Square extends Shape {
  sideLength: number;
}

Because the type defined by the TypeScript interface does not generate the corresponding type after compilation, we cannot use the instanceof operator for type detection at runtime. To realize the functions of the printShapeInfo function, we can use the in operator, the specific implementation is as follows:

function printShapeInfo(shape: Shape) {
  if ("radius" in shape) {
    console.log(`Circle's radius is: ${shape.radius}`);
  } else if ("sideLength" in shape) {
    console.log(`Square's sideLength is: ${shape.sideLength}`);
  }
}

user-defined type guards

To demonstrate user-defined type guards, let’s redefine the 3 types:

interface Shape {
  id: string;
}

interface Circle extends Shape {
  radius: number;
}

interface Square extends Shape {
  sideLength: number;
}

After defining the types related to Shape, let’s define the user-defined type guards function:

function isCircle(shape: Shape): shape is Circle {
  return "radius" in shape;
}

function isSquare(shape: Shape): shape is Square {
  return "sideLength" in shape;
}

Compared with ordinary functions, custom type guard functions return type predicates. shape is Circle in the above code is the so-called type predicate. A predicate takes the form parameterName is Type, where parameterName must be the name of a parameter from the current function signature. You can understand the role of the isCircle user-defined type guard function in this way. If the return value of the function is true, the type of the shape parameter is the Circle type.

Now that we have the isCircle and isSquare functions, we can use them in the printShapeInfo function like this:

function printShapeInfo(shape: Shape) {
  if (isCircle(shape)) {
    console.log(`Circle's radius is: ${shape.radius}`);
  } else if (isSquare(shape)) {
    console.log(`Square's sideLength is: ${shape.sideLength}`);
  }
}

equality narrowing type guards

In addition to the 4 types of guarding methods described earlier, TypeScript also supports the use of if/switch statements and equality checks, such as, ===, !===, == and != operators to narrow the types of variables.

function printValues(a: string | number, b: string | string[]) {
  if (a === b) {
    console.log(a.toUpperCase()); // (parameter) a: string
    console.log(b.toUpperCase()); // (parameter) b: string
  } else {
    console.log(a); // (parameter) a: string | number
    console.log(b); // (parameter) b: string | string[]
  }
}

In the above code, printValues function supports a and b 2 parameters, and their types are union types. When the a===bexpression evaluates to true, the types of the parameters a and b will be narrowed to string type. Of course, using the !==operator can also be used to achieve type narrowing.

function printValues2(a: string | number, b: string | string[]) {
  if (a !== b) {
    console.log(a); // (parameter) a: string | number
    console.log(b); // (parameter) b: string | string[]
  } else {
    console.log(a.toLowerCase()); // (parameter) a: string
    console.log(b.toLowerCase()); // (parameter) b: string
  }
}

The Dead Simple Typescript Trick That Saved Me 15+ Hours of Debugging Hell

I had been chasing a bug for 6+ straight hours. The kind of bug that makes you question your career choices. Property errors in nested objects, type mismatch that made zero sense, and IntelliSense that was about as helpful as a chocolate teapot.

Then I remembered the satisfies operator.

One line of code. Fifteen minutes later, the bug was dead, buried, and I was shipping a coffee instead of contemplating a career change to organic farming.

If you are still fighting with TypeScript’s type system instead of making it work for you, this might just save your sanity, too.

The Problem That Eating Your Time (And You Don’t Even Know It)

Picture this: You are building a user management system. Nested objects everywhere. Complex types that make your brain hurt. And TypeScript? It’s being that friend who points out every tiny mistake but offers zero helpful suggestions.

type UserData = {
  username: string;
  profile: {
    email: string;
    preferences: {
      theme: 'light' | 'dark';
      notifications: boolean;
    };
  };
};
// This looks fine, right? WRONG.
const user: UserData = {
  username: "john_doe",
  profile: {
    email: "john@example.com",
    preferences: {
      theme: "light",
      notifications: true,
    },
  },
};

// Good luck accessing this without TypeScript having a meltdown
console.log(user.profile.preferences.theme); 
// Sometimes works, sometimes doesn't. Depends on TypeScript's mood.

Here’s what actually happens:

  • TypeScript loses track of your exact types

  • IntelliSense becomes useless for nested objects

  • You get cryptic errors like “Property ‘theme’ does not exist on type…“

  • You waste hours adding explicit type assertions everywhere.

  • Your code looks like a Christmas tree of angle brackets, and as keywords

Sound familiar? We have all been there.

The satisfies Operator: Your New Best Friend

The satisfies operator landed in TypeScript 4.9, and honestly? It’s a game-changer. The new satisfies lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

Here’s the magic:

type UserData = {
  username: string;
  profile: {
    email: string;
    preferences: {
      theme: 'light' | 'dark';
      notifications: boolean;
    };
  };
};
// The same object, but with satisfies
const user = {
  username: "john_doe",
  profile: {
    email: "john@example.com",
    preferences: {
      theme: "light",
      notifications: true,
    },
  },
} satisfies UserData;

// Now this just WORKS. Every. Single. Time.
console.log(user.profile.preferences.theme); // "light" - TypeScript is happy

What just happened?

  • TypeScript validates your object structure ✅

  • BUT keeps the exact, precise types of your values ✅

  • IntelliSense works perfectly on nested properties ✅

  • Zero runtime overhead (it’s compile-time only) ✅

It’s like having your cake and eating it too, except the cake is type safety and. you are not getting crumbs everywhere.

Real-World Example: The API Response From Hell

Last month, I was working with an API that returned this monstrosity:

type APIResponse = {
  data: {
    users: Array<{
      id: number;
      details: {
        personal: {
          name: string;
          age: number;
        };
        settings: {
          theme: 'light' | 'dark' | 'auto';
          language: string;
        };
      };
    }>;
    metadata: {
      total: number;
      page: number;
    };
  };
  status: 'success' | 'error';
};

Without satisfies: I spent 3 hours debugging why response.data.users[0].details.settings.theme was throwing type errors, even though the data was clearly there.

With satisfies:

const response = {
  data: {
    users: [
      {
        id: 1,
        details: {
          personal: {
            name: "Sarah Connor",
            age: 35,
          },
          settings: {
            theme: "dark",
            language: "en-US",
          },
        },
      },
    ],
    metadata: {
      total: 1,
      page: 1,
    },
  },
  status: "success",
} satisfies APIResponse;
// This just works. No drama. No fuss.
const userTheme = response.data.users[0].details.settings.theme;
console.log(userTheme); // "dark"

TypeScript knew exactly what I meant, IntelliSense worked perfectly, and I could access deeply nested properties without a single type assertion.

The Power Combo: satisfies + Utility Types

Want to level up even more? Combine satisfies with TypeScript's utility types. It's like adding rocket fuel to your development workflow.

Example 1: Form Validation That Actually Works

type FormData = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  preferences: {
    newsletter: boolean;
    updates: boolean;
  };
};
// Only validate the fields the user has filled
const partialForm = {
  username: "awesome_dev",
  email: "dev@example.com",
  preferences: {
    newsletter: true,
    updates: false,
  },
} satisfies Partial<FormData>;
// No complaints about missing password fields
// Perfect for multi-step forms!

Example 2: Configuration Objects Made Simple

type AppConfig = {
  database: { host: string; port: number };
  cache: { redis: string; ttl: number };
  features: { darkMode: boolean; analytics: boolean };
};
// Pick only what you need for development
const devConfig = {
  database: { host: "localhost", port: 5432 },
  features: { darkMode: true, analytics: false },
} satisfies Pick<AppConfig, 'database' | 'features'>;

This approach has become central to how I handle complex configurations in my client projects.

5 Very Useful Tricks for TypeScript Typeof Operator

In JavaScript, you can get the type of a variable through typeof operator, do you know what the typeof operator is used in Typescript? I will introduce 5 common application scenarios of the typeof operator, which you may use in future projects.

Get the type of object

The man object is a regular JavaScript object. In Typescript, you can use an interface or type to define the type of object. With this object type, you can use TypeScript’s built-in utility types, such as Partial, Required, Pick, or Readonly, to handle object types to meet different needs.

For simple objects, this may not be a big deal. But for large, complex objects with deeper nesting levels, manually defining their types can be mind-numbing. To solve this problem, you can use typeof operator.

type Person = typeof man;
type Address = Person["address"];

Compared to manually defining the type before. It becomes much easier to use the typeof operator. Person["address"] is an indexed access type used to look up a specific property (address) on another type (Person type).

Get a Type That Represents All Enum Keys as Strings.

In Typescript, enum types are special types that get combined into regular JavaScript objects.

Therefore, you can also use the typeof operator on enum types. But this is often not of much practical use, and when dealing with enum types, it is usually combined with the keyof operator.

Get The Type of The Function Object

There is another more common scenario in which the typeof operator is used in your work. After obtaining the corresponding function type, you can continue to use Typescript’s built-in ReturnType and Parameters utility types to obtain the function’s return value type and parameter type, respectively.

Get the Type of the Class Object

Since the typeof operator can handle the function object, can it handle Class objects? The answer is yes.

In the above code, createPoint is a factory function that creates an instance of the Point class. Through the typeof operator, you can obtain the corresponding construct signature of the Point class, so as to realize the corresponding type verification. When defining the parameter type of a Constructor, if the typeof operator is not used, the following error message will appear:

Get a More Precise Type

When using the typeof operator, if you want to get a more precise type, then you combine it with the cost assertion introduced in Typescript version 3.4. This is used in the following way.

As you see in the above figure, after using the cost assertion and then using the typeof operator, you can obtain a more precise type.

Advanced Typescript Patterns

TypeScript has revolutionized JavaScript development by bringing strong typing and advanced patterns to the ecosystem. In this section, we will explore advanced Typescript patterns that not only make your codebase more robust but also improve your JavaScript development skills.

Anders Hejlsberg’s “Type First, Code Second” Philosophy

The creator of TypeScript himself advocates for designing your types before writing the implementation. Anders often emphasizes: “Types are documentation that never lies.”

// Instead of this messy approach
function processUser(user: any) {
  if (user.name && user.email) {
    // Hope for the best 🤞
    return user.name.toUpperCase();
  }
}

// Anders' approach: Define the contract first
interface User {
  readonly id: string;
  name: string;
  email: string;
  preferences?: UserPreferences;
}

interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: boolean;
}

function processUser(user: User): string {
  // TypeScript guarantees these properties exist
  return user.name.toUpperCase();
}

This pattern alone cut my debugging time by 60%. When you define types upfront, TypeScript becomes your pair programmer, catching errors before they hit production.

Matt Pocock’s Utility Type Mastery

Matt Pocock, the TypeScript educator extraordinaire, is famous for his utility type patterns that make complex scenarios simple. His favorite productivity hack? Template literal types for API routes:

// Matt's genius pattern for type-safe API routes
type APIRoutes = 
  | '/users'
  | '/users/:id'
  | '/products'
  | '/products/:id/reviews';

type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param]: string } & ExtractParams<`/${Rest}`>
  : T extends `${string}:${infer Param}`
  ? { [K in Param]: string }
  : {};

// Now your API calls are bulletproof
function apiCall<T extends APIRoutes>(
  route: T,
  params: ExtractParams<T>
): Promise<any> {
  // Implementation with full type safety
}

// Usage - TypeScript knows exactly what params you need!
apiCall('/users/:id', { id: '123' }); // ✅
apiCall('/users/:id', { userId: '123' }); // ❌ Error!

This connects perfectly with what I wrote about in my previous article on using TypeScript utility types for clean and scalable code. The combination of these patterns can transform how you handle large codebases.

Kent C. Dodds’ “Make Invalid States Impossible” Pattern

Kent’s philosophy is brilliant: instead of handling invalid states, design your types so they can’t exist in the first place.

// Bad: Multiple possible invalid states
interface LoadingState {
  isLoading: boolean;
  error: string | null;
  data: any | null;
}

// Kent's approach: Discriminated unions make invalid states impossible
type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function useAsyncData<T>(): AsyncState<T> {
  // Now you can't accidentally have loading=true AND data present
  // TypeScript enforces logical consistency
}

// Usage becomes bulletproof
function UserProfile({ asyncState }: { asyncState: AsyncState<User> }) {
  switch (asyncState.status) {
    case 'loading':
      return <Spinner />; // asyncState.data doesn't exist here!
    case 'success':
      return <div>{asyncState.data.name}</div>; // data is guaranteed!
    case 'error':
      return <div>Error: {asyncState.error}</div>; // error is guaranteed!
    default:
      return <div>No data yet</div>;
  }
}

This pattern eliminated an entire class of bugs from my applications. No more checking if loading is true while data also exists — TypeScript makes it impossible.

Josh Goldberg’s Conditional Type Wizardry

Josh Goldberg, author of “Learning TypeScript,” teaches advanced conditional patterns that feel like magic:

// Josh's pattern for smart function overloads
type SmartFunction<T> = T extends string 
  ? (input: T) => string
  : T extends number
  ? (input: T) => number
  : T extends boolean
  ? (input: T) => string
  : never;

// One function, multiple behaviors based on input type
function smartProcess<T extends string | number | boolean>(input: T): ReturnType<SmartFunction<T>> {
  if (typeof input === 'string') {
    return input.toUpperCase() as any;
  }
  if (typeof input === 'number') {
    return input * 2 as any;
  }
  if (typeof input === 'boolean') {
    return input.toString() as any;
  }
  throw new Error('Invalid input type');
}

// Usage - TypeScript infers return types perfectly
const result1 = smartProcess("hello"); // string
const result2 = smartProcess(42); // number  
const result3 = smartProcess(true); // string

Marius Schulz’s “Brand Types” for Extra Safety

Marius introduced me to branded types — a pattern that prevents subtle bugs by making similar types incompatible:

// Create branded types for extra safety
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };

function createUserId(id: string): UserId {
  return id as UserId;
}

function createProductId(id: string): ProductId {
  return id as ProductId;
}

// Now these functions can't be called with wrong IDs
function getUser(id: UserId): User { /* ... */ }
function getProduct(id: ProductId): Product { /* ... */ }

const userId = createUserId("user-123");
const productId = createProductId("prod-456");

getUser(userId); // ✅
getUser(productId); // ❌ Error! Can't use ProductId as UserId

This pattern has saved me countless times from passing wrong IDs to functions.

Type-Level Programming

Conditional Types

// Advanced conditional type pattern
type IsArray<T> = T extends Array<any> ? true : false;
type IsString<T> = T extends string ? true : false;

// Practical example: API response handler
type ApiResponse<T> = {
    data: T;
    status: number;
    message: string;
};

type ExtractData<T> = T extends ApiResponse<infer U> ? U : never;

// Usage example
interface UserData {
    id: number;
    name: string;
}

type UserApiResponse = ApiResponse<UserData>;
type ExtractedUserData = ExtractData<UserApiResponse>; // Returns UserData type

Template Literal Types

// Define valid HTTP methods
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = '/users' | '/posts' | '/comments';

// Create API route types
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;

// Validate routes at compile time
const validRoute: ApiRoute = 'GET /users';     // ✅ Valid
const invalidRoute: ApiRoute = 'PATCH /users';  // ❌ Type error

Advanced Generic Patterns

Factory with Generic Constraints

interface HasId {
    id: number;
}

interface HasTimestamps {
    createdAt: Date;
    updatedAt: Date;
}

// Generic factory with constraints
class EntityFactory<T extends HasId & HasTimestamps> {
    create(data: Omit<T, keyof HasTimestamps>): T {
        return {
            ...data,
            createdAt: new Date(),
            updatedAt: new Date()
        } as T;
    }

    update(entity: T, data: Partial<Omit<T, keyof HasId | keyof HasTimestamps>>): T {
        return {
            ...entity,
            ...data,
            updatedAt: new Date()
        };
    }
}

// Usage example
interface User extends HasId, HasTimestamps {
    id: number;
    name: string;
    email: string;
}

const userFactory = new EntityFactory<User>();
const user = userFactory.create({ id: 1, name: 'John', email: 'john@example.com' });

Type-Safe Event Emitter

type EventMap = {
    'user:login': { userId: string; timestamp: number };
    'user:logout': { userId: string; timestamp: number };
    'error': { message: string; code: number };
}

class TypedEventEmitter<T extends Record<string, any>> {
    private listeners: Partial<Record<keyof T, Function[]>> = {};

    on<K extends keyof T>(event: K, callback: (data: T[K]) => void) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }
        this.listeners[event]?.push(callback);
    }

    emit<K extends keyof T>(event: K, data: T[K]) {
        this.listeners[event]?.forEach(callback => callback(data));
    }
}

// Usage
const emitter = new TypedEventEmitter<EventMap>();

emitter.on('user:login', ({ userId, timestamp }) => {
    console.log(`User ${userId} logged in at ${timestamp}`);
});

// Type-safe emit
emitter.emit('user:login', { 
    userId: '123', 
    timestamp: Date.now() 
});

Discriminated Unions

// Define possible states with discriminated union
type AsyncState<T, E = Error> = 
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: E };

// Generic async data handler
class AsyncData<T, E = Error> {
    private state: AsyncState<T, E> = { status: 'idle' };

    setState(newState: AsyncState<T, E>) {
        this.state = newState;
        this.render();
    }

    render() {
        switch (this.state.status) {
            case 'idle':
                console.log('Waiting to start...');
                break;
            case 'loading':
                console.log('Loading...');
                break;
            case 'success':
                console.log('Data:', this.state.data);
                break;
            case 'error':
                console.log('Error:', this.state.error);
                break;
        }
    }
}

// Usage
interface UserProfile {
    id: string;
    name: string;
}

const userProfile = new AsyncData<UserProfile>();

Utility Types Deep Dive

// Make all properties optional and nullable
type Nullable<T> = { [P in keyof T]: T[P] | null };

// Make specific properties required
type RequiredProps<T, K extends keyof T> = T & { [P in K]-?: T[P] };

// Deep partial type
type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Usage example
interface Config {
    api: {
        baseUrl: string;
        timeout: number;
        headers: {
            authorization: string;
            contentType: string;
        };
    };
    cache: {
        enabled: boolean;
        ttl: number;
    };
}

type PartialConfig = DeepPartial<Config>;

const config: PartialConfig = {
    api: {
        baseUrl: 'https://api.example.com',
        headers: {
            authorization: 'Bearer token'
        }
    }
};

Factory Pattern

// Abstract product interfaces
interface Button {
    render(): void;
    onClick(): void;
}

interface Input {
    render(): void;
    getValue(): string;
}

// Abstract factory interface
interface UIFactory {
    createButton(): Button;
    createInput(): Input;
}

// Concrete implementations
class MaterialButton implements Button {
    render() { console.log('Rendering Material button'); }
    onClick() { console.log('Material button clicked'); }
}

class MaterialInput implements Input {
    render() { console.log('Rendering Material input'); }
    getValue() { return 'Material input value'; }
}

class MaterialUIFactory implements UIFactory {
    createButton(): Button {
        return new MaterialButton();
    }
    createInput(): Input {
        return new MaterialInput();
    }
}

// Usage with dependency injection
class Form {
    constructor(private factory: UIFactory) {}

    render() {
        const button = this.factory.createButton();
        const input = this.factory.createInput();
        button.render();
        input.render();
    }
}

State Management Patterns

// Action types with discriminated unions
type Action =
    | { type: 'ADD_TODO'; payload: { text: string } }
    | { type: 'TOGGLE_TODO'; payload: { id: number } }
    | { type: 'DELETE_TODO'; payload: { id: number } };

interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

interface State {
    todos: Todo[];
    loading: boolean;
}

// Type-safe reducer
function reducer(state: State, action: Action): State {
    switch (action.type) {
        case 'ADD_TODO':
            return {
                ...state,
                todos: [...state.todos, {
                    id: Date.now(),
                    text: action.payload.text,
                    completed: false
                }]
            };
        case 'TOGGLE_TODO':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload.id
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        case 'DELETE_TODO':
            return {
                ...state,
                todos: state.todos.filter(todo => todo.id !== action.payload.id)
            };
    }
}

Builder Patterns

class QueryBuilder<T> {
    private query: Partial<T> = {};
    private conditions: Array<(item: T) => boolean> = [];

    where<K extends keyof T>(key: K, value: T[K]): this {
        this.query[key] = value;
        return this;
    }

    whereIn<K extends keyof T>(key: K, values: T[K][]): this {
        this.conditions.push((item: T) => 
            values.includes(item[key])
        );
        return this;
    }

    build(): (item: T) => boolean {
        const query = this.query;
        const conditions = this.conditions;

        return (item: T) => {
            const matchesQuery = Object.entries(query).every(
                ([key, value]) => item[key as keyof T] === value
            );

            const matchesConditions = conditions.every(
                condition => condition(item)
            );

            return matchesQuery && matchesConditions;
        };
    }
}

// Usage example
interface User {
    id: number;
    name: string;
    age: number;
    role: 'admin' | 'user';
}

const query = new QueryBuilder<User>()
    .where('role', 'admin')
    .whereIn('age', [25, 30, 35])
    .build();

const users: User[] = [
    { id: 1, name: 'John', age: 30, role: 'admin' },
    { id: 2, name: 'Jane', age: 25, role: 'user' }
];

const results = users.filter(query);

Type-Safe API Client

// API endpoints definition
interface ApiEndpoints {
    '/users': {
        GET: {
            response: User[];
            query: { role?: string };
        };
        POST: {
            body: Omit<User, 'id'>;
            response: User;
        };
    };
    '/users/:id': {
        GET: {
            params: { id: string };
            response: User;
        };
        PUT: {
            params: { id: string };
            body: Partial<User>;
            response: User;
        };
    };
}

// Type-safe API client
class ApiClient {
    async get<
        Path extends keyof ApiEndpoints,
        Method extends keyof ApiEndpoints[Path],
        Endpoint extends ApiEndpoints[Path][Method]
    >(
        path: Path,
        config?: {
            params?: Endpoint extends { params: any } ? Endpoint['params'] : never;
            query?: Endpoint extends { query: any } ? Endpoint['query'] : never;
        }
    ): Promise<Endpoint extends { response: any } ? Endpoint['response'] : never> {
        // Implementation
        return {} as any;
    }

    async post<
        Path extends keyof ApiEndpoints,
        Method extends keyof ApiEndpoints[Path],
        Endpoint extends ApiEndpoints[Path][Method]
    >(
        path: Path,
        body: Endpoint extends { body: any } ? Endpoint['body'] : never
    ): Promise<Endpoint extends { response: any } ? Endpoint['response'] : never> {
        // Implementation
        return {} as any;
    }
}

// Usage
const api = new ApiClient();

// Type-safe API calls
const users = await api.get('/users', { query: { role: 'admin' } });
const user = await api.post('/users', { name: 'John', age: 30, role: 'user' });

Typescript Types Scared Me - Until I Learned These 4 Rules

When I first encountered TypeScript’s infer and conditional types, I closed the tab and hoped I would never see them again. They looked like dark magic - abstract symbols twisted around angle brackets and seemed designed to make my brain hurt.

Types like T extends (infer U)[] ? U : never or DistributiveConditional<T> made me question my entire career choice. But here’s the thing that changed everything: there aren’t actually complicated concepts dressed up in scary syntax.

Once I understood 3 simple mental models, they stopped being scary. In this section, I will walk you through the exact concepts that finally made these types click for me, with real-world examples, not theory.

The fear is real, and it’s not just about you

Let me be clear: If you have felt intimidated by TypeScript’s advanced types, you are in good company. Even experienced developers struggle with conditional types and the infer keywords when they first encounter them.

I remember staring at the code like this and feeling completely lost.

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

What the hell was infer R supposed to mean? Why are there all question marks and colons? It looked like someone had thrown punctuation at a keyboard and called it a day.

But here is what I wish someone had told me earlier: these advanced types follow predictable patterns. Once you understand the underlying mental models, you will see them everywhere — and more importantly, you will know how and where to use them.

Rule 1: Conditional Mirror Control Flow

“If you understand if…else, you can understand conditional types.”

This was my first breakthrough. Conditional types in TypeScript work exactly like conditional statements in JavaScript, just at the type level instead of the value level.

The basic pattern is simple:

type MyType<T> = T extends SomeCondition ? TrueResult : FalseResult;

Let’s start with something straightforward:

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>; // true
type B = IsString<123>;     // false
type C = IsString<boolean>; // false

See? It’s just an if statement for types. When the type on the left of extends is assignable to the one on the right, you get the type in the first branch (the "true" branch); otherwise, you get the type in the latter branch (the "false" branch).

Here’s where it gets practical. Let’s say you’re building a component that should behave differently based on its props:

type ButtonProps<T extends boolean> = {
  loading: T;
} & (T extends true 
  ? { onClick?: never; disabled: true } 
  : { onClick: () => void; disabled?: boolean }
);

// When loading is true, onClick is forbidden and disabled is required
const loadingButton: ButtonProps<true> = {
  loading: true,
  disabled: true,
  // onClick: () => {} // ❌ Type error! Can't have onClick when loading
};

// When loading is false, onClick is required
const normalButton: ButtonProps<false> = {
  loading: false,
  onClick: () => console.log('Clicked!'),
  disabled: false
};

The mental model: Think of conditional types as TypeScript’s way of saying: “If this type looks like that, then give me this other type, otherwise give me something else“

Rule 2: Distributive Magic Happens with Naked Types

“When you pass a union, TypeScript loops over each part—unless you stop it.”

This one took me way longer to understand, but it’s incredibly powerful once it clicks.

When conditional types act on a generic type, they become distributed when given a union type. That means TypeScript automatically applies the conditional type to each member of the union separately.

Here’s the key: This only happens with “naked” type parameters.

// This is "naked" - T appears directly in the extends clause
type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<'a' | 'b' | 'c'>;
// Result is: 'a'[] | 'b'[] | 'c'[]
// NOT: ('a' | 'b' | 'c')[]

Why does this happen? TypeScript takes the union 'a' | 'b' | 'c' and distributes it:

  • 'a' extends any ? 'a'[] : never'a'[]

  • 'b' extends any ? 'b'[] : never'b'[]

  • 'c' extends any ? 'c'[] : never'c'[]

Then it unites the results: 'a'[] | 'b'[] | 'c'[]

But here’s how you can turn off this behavior when you don’t want it:

// Wrap T in brackets to make it "non-naked"
type NoDistribute<T> = [T] extends [any] ? T[] : never;

type Result2 = NoDistribute<'a' | 'b' | 'c'>;
// Result2 is: ('a' | 'b' | 'c')[]

Real example: Filtering types from a union.

type NonNullable<T> = T extends null | undefined ? never : T;

type Clean = NonNullable<string | null | number | undefined>;
// Clean is: string | number
// The null and undefined get filtered out automatically!

This distributive property can be used to filter union types, which is exactly how TypeScript’s built-in Exclude utility-type works.

Rule 3: Infer lets you peek inside a type

“You can extract types from other types like a pattern matcher.”

The infer keyword was the final boss of my TypeScript learning journey. But once I understood it, everything clicked.

Think of infer as saying: "Hey TypeScript, I don’t know what this type is yet, but when you figure it out, store it in this variable so I can use it.”

Conditional types provide us with a way to infer from types we compare against in the true branch using the infer keyword.

Here’s the classic example:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getName(): string { return "John"; }
function getAge(): number { return 25; }

type NameType = ReturnType<typeof getName>; // string
type AgeType = ReturnType<typeof getAge>;   // number

What’s happening here?

  1. We check if T looks like a function (...args: any[]) => something

  2. If it does, we say “whatever that ‘something’ is, call it R"

  3. Then we return R

  4. If it doesn’t look like a function, return never

Let’s build something more practical — extracting array element types:

type ArrayElement<T> = T extends (infer U)[] ? U : never;

type StringArray = string[];
type NumberArray = number[];

type StringType = ArrayElement<StringArray>; // string
type NumberType = ArrayElement<NumberArray>; // number
type NotArray = ArrayElement<boolean>; // never

Here’s where it gets really powerful — extracting component props:

type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never;

const MyButton: React.FC<{ label: string; onClick: () => void }> = (props) => (
  <button onClick={props.onClick}>{props.label}</button>
);

type MyButtonProps = PropsOf<typeof MyButton>;
// MyButtonProps is: { label: string; onClick: () => void }

Multiple infer declarations work too:

type FunctionInfo<T> = T extends (first: infer A, second: infer B) => infer R 
  ? { args: [A, B]; return: R } 
  : never;

type LoginFunction = (username: string, password: string) => Promise<boolean>;

type LoginInfo = FunctionInfo<LoginFunction>;
// LoginInfo is: { args: [string, string]; return: Promise<boolean> }

Rule 4: Map Types Aren’t Hard if You Start Smart

Now that you understand conditional types and infer, mapped types will feel like a breeze.

Mapped types transform existing types. Think of them as a for...in loop for type properties.

type Optional<T> = {
  [K in keyof T]?: T[K];
}

In plain English: “For each property K in type T, make a new property with the same name but optional, and the same type T[K]."

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

type PartialUser = Optional<User>;
// PartialUser is: {
//   id?: number;
//   name?: string;
//   email?: string;
// }

Let’s build something more interesting — converting all properties to strings:

type Stringify<T> = {
  [K in keyof T]: string;
}


type StringifiedUser = Stringify<User>;
// StringifiedUser is: {
//   id: string;
//   name: string;
//   email: string;
// }

Combining mapped types with conditional types:

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

class UserService {
  id: number = 1;
  name: string = "John";
  save(): void {}
  delete(): void {}
}

type UserData = NonFunctionProperties<UserService>;
// UserData is: { id: number; name: string }
// Methods are filtered out!

Putting it all together: A Real-World Example

Let’s build a type that extracts the payload type from Redux actions:

// Our action types
type LoginAction = { type: 'LOGIN'; payload: { username: string; password: string } };
type LogoutAction = { type: 'LOGOUT'; payload: null };
type UpdateProfileAction = { type: 'UPDATE_PROFILE'; payload: { name: string; email: string } };

type Actions = LoginAction | LogoutAction | UpdateProfileAction;

// Extract payload type for a specific action
type PayloadOf<T, ActionType extends string> = T extends { type: ActionType; payload: infer P } 
  ? P 
  : never;

// Usage
type LoginPayload = PayloadOf<Actions, 'LOGIN'>;
// LoginPayload is: { username: string; password: string }

type LogoutPayload = PayloadOf<Actions, 'LOGOUT'>;
// LogoutPayload is: null

type UpdatePayload = PayloadOf<Actions, 'UPDATE_PROFILE'>;
// UpdatePayload is: { name: string; email: string }

What’s happening:

  1. We use distributive conditional types to check each action in the union

  2. We use infer to extract the payload type when the action type matches

  3. TypeScript gives us exactly the payload type we need!

Now that you understand these three rules, you’ll start seeing patterns everywhere in TypeScript’s built-in utility types:

  • Pick<T, K> uses mapped types

  • Exclude<T, U> uses distributive conditional types

  • ReturnType<T> uses infer

  • Parameters<T> combining conditional types with infer

Clean React With TypeScript

Today, I use TypeScript on a daily basis and actually like it quite a bit, even though I regularly break the compiler. One thing that took me quite some time, though, was figuring out the best way to add TypeScript to all the React patterns and features. I have seen it done in so many ways, ending up confused by all the different types in the library itself.

To help you avoid that struggle or/and to improve your existing codebase, this article explores different use cases and - in my opinion - most elegant solutions.

Components

First of all, let’s talk about components.

They are the heart of every React application and probably the thing you write the most often. Even since the introduction of hooks, most components are plain JavaScript functions that take a set of props and return some markup, usually in the form of JSX.

Given that, typing them is definitely straightforward. The only real constraint is that it always takes a single argument - an object of properties, to be precise.

  1. Basic Props

Let’s start with some primitive props first. In this example, we have a component that takes a title and a description. Both are strings, and description is optional.

type Props = {
  title: string
  description?: string
}

function ProductTile({ title, description }: Props) {
  return (
    <div>
      <div>{title}</div>
      {description && <div>{description</div>}
    </div>
  )
}
  1. Children

So far, so good. Now, a common use case is to pass children to a component in order to render nested components. You might be inclined to add it to your type, but React actually provides a special type that could help you reduce redundancy.

It’s called PropsWithChildren and it’s a generic type that takes your existing props type and adds the children for you.

import { ReactNode, PropsWithChildren } from 'react'

type Props = {
  title: string
}

function ProductTile({ title, children }: PropsWithChildren<Props>) {
  return (
    <div>
      <div>{title}</div>
      {children}
    </div>
  )
}

Tip: This argument is optional. If your component only takes children, you can pass PropsWithChildren on its own.

  1. Piping Props

Another common thing to do is piping props into inner components. For example, you might have a component that itself renders our ProductTile component, but also accepts additional props for customization.

import ProductTile from './ProductTile'

type Props = {
  color: string
  title: string
}

function ProminentProductTile({ color, title }: Props) {
  return (
    <div style={{ background: color }}>
      <ProductTile title={title} />
    </div>
  )
}

While this is totally fine for primitive types such as strings or numbers, it can become cumbersome to repeat those types if you are dealing with complex records or functions, especially if you have to pass a value through multiple components.

Remember the DRY rule? Instead of repeating the same types over and over, we can leverage another generic provided by React, which is ComponentProps. It takes the type of a component that we can get by typeof and returns its props type. We can then use indexed access to get the specific value.

import { ComponentProps } from 'react'

import ProductTile from './ProductTile'

type ProductTileProps = ComponentProps<typeof ProductTile>
type Props = {
  color: string
  title: ProductTileProps['title']
}

You can argue that we can achieve the same by simply exporting the type from ProductTile. And you are right, that works too, but it’s also more prone to errors if you change your types and/or extend them at some point.

  1. Spreading Props

Similar to piping props, sometimes we want to spread all extra props to some underlying component. Imagine the ProminentProductTile should pipe both title and description.

Instead of specifying each property one by one, we can simply extend the type we can leveraging ComponentProps once again.

import { ComponentProps } from 'react'

import ProductTile from './ProductTile'

type Props = {
  color: string
} & ComponentProps<typeof ProductTile>

function ProminentProductTile({ color, ...props }: Props) {
  return (
    <div style={{ background: color }}>
      <ProductTile {...props} />
    </div>
  )
}

Tip: If you only want to spread a subset of those types, you can use a Typescript built-in Pick to do so. For example:

type ProductTileProps = ComponentProps<typeof ProductTile>

type Props = {
  color: string
} & Pick<ProductTileProps, 'title' | 'description'>

HTML elements

The same pattern also applies to HTML primitives. If you want to pass down all remaining props to e.g, button, we can simply use ComponentProps<“button“>.

Note: There's also React.JSX.IntrinsicElements["button"] which refers to the identical type, but I would recommend you use only one for consistency and readability, and I generally prefer the first as it’s easier to use.

Extra: For certain edge cases where we only want to pass down valid HTML attributes - excluding React-specific props such as ref and key - we can also use specific attribute types, e.g. React.ButtonHTMLAttributes<HTMLButtonElement>. However, I have not yet encountered a use case for those, as it’s once again more to type, I still prefer the shorter ComponentProps<“button“>.

Passing JSX

Now that we have covered simple props, let’s look at some advanced use cases. Sometimes, passing primitive props is not enough, and we want to pass down the raw JSX to render nested content. While dependency injection generally makes your component less predictable, it’s a great way to customize generic components. This is especially useful when we are already passing children, but need to inject additional makeup at a specific position.

Luckily, React provides us a another useful type called ReactNode. Imagine a layout component that wraps your whole application and also receives the sidebar to render a dedicated navigation in a predefined slot.

import { PropsWithChildren, ReactNode } from 'react'

type Props = {
  title: string
  sidebar: ReactNode
}

function Layout({ title, children, sidebar }: PropsWithChildren<Props>) {
  return (
    <>
      <div className="sidebar">{sidebar}</div>
      <main className="content">
        <h1>{title}</h1>
        {children}
      </main>
    </>
  )
}
// Now we can pass whatever we want
const sidebar = (
  <div>
    <a data-selected href="/shoes">
      Shoes
    </a>
    <a href="/watches">Watches</a>
    <a href="/shirts">Shirts</a>
  </div>
)

const App = (
  <Layout title="Running shoes" sidebar={sidebar}>
    {/* Page content */}
  </Layout>
)

Bonus: PropsWithChildren is actually using ReactNode under the hood. A custom implementation would look something like this:

type PropsWithChildren<Props = {}> = { children: ReactNode } & Props

Passing Components

Passing JSX is great when you want maximum flexibility. But what if you want to restrict rendering to certain components or pass some props to that subtree as well, without moving all the logic into a single component,t bloating it even more?

What became popular as the render-prop pattern(new tab) is exactly that: dependency injection with constraints.
Once again, React has a neat type to help us with that called ComponentType.

import { ComponentType } from 'react'

type ProductTileProps = {
  title: string
  description?: string
}

type Props = {
  render: ComponentType<ProductTileProps>
}

function ProductTile({ render }: Props) {
  // some logic to compute props

  return render(props)
}

Extra: This is also quite nice for third-party components via d.ts files:

declare module 'some-lib' {
  type Props = {
    title: string
    description?: string
  }

  export const ProductTile: ComponentType<Props>
}

Specific Components

If we only allow a specific component, the code is even simpler as we can use the built-in typeof operator(new tab) to do so.

import { ComponentProps } from 'react'

import Icon from './Icon'

type Props = {
  icon?: typeof Icon
} & ComponentProps<'button'>

function Button({ icon: Icon, children, ...props }: Props) {
  return (
    <button {...props}>
      {icon && <Icon size={24} />}
      {children}
    </button>
  )
}

Inferring Props

This is a common pattern used for generic (layout) components. It's often used in component libraries and can be a great foundation for creating flexible layouts in React.

Usually, you pass an as or component prop and the component becomes that, including its props and types. For example:

const App = (
  <>
    {/* this throws as div doesn't have a value prop */}
    <Box as="div" value="foo" />
    {/* this works however */}
    <Box as="input" value="foo" />
  </>
)

The best part is that it also works with custom components, giving us endless flexibility.
Alright, but how do we achieve this? Instead of explaining what Matt Pocock(new tab) from Total TypeScript(new tab) already did with a great article, I'm just going to link it here: Passing Any Component as a Prop and Inferring Its Props(new tab).

State Management

Managing state is probably the most common use case for hooks, and React provides both useState for simple cases as well as the useReducer hook for more complex scenarios.

  1. useState

By default, useState automatically infers the type of the value based on the initial value that is passed. That means, if we have a simple counter state with 0 as the default state, we don’t have to type anything, and it just works:

import { useState } from 'react'

function Counter() {
  const [counter, setCounter] = useState(0)

  // component logic
}

However, if we don’t pass a default value, work with nullable values, or the default value doesn’t represent the full type,e.g., when using objects with optional keys, we have to provide a type for it to work properly.

Luckily, React allows us to pass an optional type to tell it what values to accept.

import { useState } from 'react'

type AuthState = {
  authenticated: boolean
  user?: {
    firstname: string
    lastname: string
  }
}

type Todo = {
  id: string
  title: string
  completed: boolean
}

function TodoList() {
  // without passing AuthState, we wouldn't be able to set the user
  const [authState, setAuthState] = useState<AuthState>({
    authenticated: false,
  })
  // data is loaded asynchronously and thus null on first render
  const [data, setData] = useState<Array<Todo> | null>(null)

  // component logic
}
  1. useReducer

When working with reducers, there is no type inference because we have to actively type the reducer anyway. Its first argument (state) will be used to infer the type and also to type-check the initial state that is being passed to the hook.

import { useReducer } from 'react'

type State = number
type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset'; payload: number }

function reducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
    case 'reset':
      return action.payload
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, 0)

  // component logic
}

After all, it's all just pure TypeScript and no React specifics here.

Refs

Refs, short for reference, provide a way to directly access and interacts with the DOM elements or React components.

While I would recommend you only use refs when necessary and rely on components and states whenever possible, they can be quite useful for focus management or reading/manipulating a node directly.

Note: Ref can be used for all kind of things and values, but we will focus only on HTML elements

  1. Using Refs

React provides a convenient hook useRef that creates a ref inside a functional component.

In order to get proper types and no compiler errors, we have to provide a type. Since the element is not yet mounted on first render, we also pass null as a default.

import { useRef, ComponentProps } from 'react'

function Button(props: ComponentProps<'button'>) {
  const ref = useRef<HTMLButtonElement>(null)

  return <button ref={ref} {...props} />
}
  1. Forwarding Refs

Note: Forwarding refs will soon be a thing of the past. Once React 19 hits, refs will be forwarded automatically, with no need to wrap components in forwardRef anymore.

Forwarding refs is necessary when we want to pass a ref into a custom component. Let’s take the button with the icon from above. Something I have seen a lot in code bases is typing the props and the ref individually.

import { forwardRef, ComponentProps, ForwardedRef } from 'react'

import Icon from './Icon'

type Props = {
  icon?: typeof Icon
} & ComponentProps<'button'>

const Button = forwardRef(
  (
    { icon, children, ...props }: Props,
    ref: ForwardedRef<HTMLButtonElement>
  ) => {
    return (
      <button ref={ref} {...props}>
        {icon && <Icon size={24} />}
        {children}
      </button>
    )
  }
)

I always find this code quite hard to read as there's a lot going on with many parenthesis and brackets. But, it doesn't have to be that way! forwardRef accepts two optional types to initialise it's function button:

const Button = forwardRef<HTMLButtonElement, Props>(
  ({ icon, children, ...props }, ref) => {
    return (
      <button ref={ref} {...props}>
        {icon && <Icon size={24} />}
        {children}
      </button>
    )
  }
)

This results not only in more readable code, but also reduces some of the boilerplate, as we don't need ForwardedRef at all.

  1. Passing Refs

Sometimes, we don't want to forward a ref directly, but rather pass a ref to another element. Imagine you have a popover component and want to pass which element it should anchor to. We can do that by passing a ref. To do so, React provides a RefObject type.

import { RefObject } from 'react'

type Props = {
  anchor: RefObject<HTMLElement>
}

function Popover({ anchor }: Props) {
  // position component according to the anchor ref
}

Tip: Generally speaking, I recommend using more universal types such as HTMLElement unless you need specific attributes or want to limit the API, e.g., when building a component library. Why? Because HTMLDivElement also satisfies HTMLElement, but HTMLSpanElement doesn't satisfy HTMLDivElement.

Events

Last but not least, let’s talk about events and event listeners

We usually encounter them in two ways:

  • Passing event listeners to components directly via props, e.g. onClick, onBlur

  • Adding event listeners in effects when targeting different elements, e.g. scroll mouseup

However, before we dive into both use cases, we should first talk about the different event types, where they come from, and what the differences are.

  1. MouseEvent vs. React.mouseEvent

At the beginning, this really confused me. There's both a global MouseEvent as well as MouseEvent exported by React. They are both used for event listeners, and I have seen them mixed up more than once.

To put it simply, the difference is that the global built-in MouseEvent refers to native JavaScript events, while the other one is specifically tailored for React's Synthetic Event System.

They have a lot in common and can often be used interchangeably, but the React version also accounts for browser incompatibilities and includes some React-specific properties such as persist.

Besides mouse events, there are all sorts of events for every kind of event listener.

Note: In older versions prior to React 17, synthetic events would also be pooled, which means that the event object is reused for performance reasons.

  1. Passing Event Listeners

The first use case is passing event listeners to components. When doing so, we're using React's own event system and thus should use the special events provided by React.

The most common example is passing a onClick handler to a clickable component.

Note: Once again, we should most likely be using ComponentProps<"button">["onClick"] here, but just for the sake of exploring the types, we will write the type ourselves.

import { MouseEventHandler } from 'react'

type Props = {
  onClick: MouseEventHandler<HTMLButtonElement>
}

function Button({ onClick }: Props) {
  return <button onClick={onClick} />
}

Another common thing is to manipulate event listeners, e.g., when submitting a form. In that case, we have to type the event to get the proper type.

// using named imports would overwrite the native events here
// it's more safe to do it this way, in case we want to use both in a single file
import * as React from 'react'

function Login() {
  return (
    <form
      onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()

        // login logic
      }}>
      {/* Login form */}
    </form>
  )
}
  1. Attaching Event Listeners

Finally, the last thing to discuss is attaching event listeners. This is primarily useful when dealing with events that can't be passed to the component directly such as a global scroll listener.

Typically, we register the event listener in a useEffect.
Since those are native event listeners and have nothing to do with React, we get the native events and should use the native types as well.

import { useEffect } from 'react'

function Navigation() {
  useEffect(() => {
    const onScroll = (e: Event) => {
      // do something when scrolling
    }

    document.addEventListener('scroll', onScroll)
    return () => document.removeEventListener('scroll', onScroll)
  }, [])

  // component logic
}

React TypeScript Best Practices

Using as const for Literal Type Inference

When defining static values like status codes, roles, or feature flags, as const helps infer literal values instead of general types.

❌ Without as const:

const roles = ["admin", "user", "moderator"];
type Role = typeof roles[number]; // string

✅ With as const:

const roles = ["admin", "user", "moderator"] as const;
type Role = typeof roles[number]; // "admin" | "user" | "moderator"

Use this for dropdowns, tabs, or enum-like values to get strong type safety and autocomplete.

Discriminated Unions for Conditional Component Logic

Discriminated (tagged) unions let you safely switch between component variations without manual type checks.

type Field =
  | { type: "text"; label: string }
  | { type: "checkbox"; checked: boolean };

function RenderField(field: Field) {
  if (field.type === "text") return <input placeholder={field.label} />;
  if (field.type === "checkbox") return <input type="checkbox" checked={field.checked} />;
}

🔍 TypeScript narrows the type based on field.type, so you get complete inference in each branch. No type casting, no fuss.

Generics in Custom Hooks

Want reusable data-fetching hooks? Generics are the key:

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  // fetch logic...
  return { data };
}

Usage:

type User = { id: number; name: string };
const { data } = useFetch<User>("/api/user");

Your data is fully typed — no need to cast or guess field names.

Utility Types for Cleaner Props

Use Partial, Pick, Omit, Record, etc., to build more flexible components:

type InputProps = Pick<React.InputHTMLAttributes<HTMLInputElement>, "type" | "value">;

const Input = (props: InputProps) => <input {...props} />;

Common patterns:

  • Omit<T, "prop"> — Remove a specific prop

  • Partial<T> — Make all fields optional

  • Record<K, V> — Map keys to values

These reduce boilerplate while increasing maintainability.

Infer Props from Zod or API Contracts

Instead of duplicating types, infer them directly from validation schemas:

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof userSchema>;

Use User for API requests, forms, and even Redux or Zustand states. One source of truth. Zero inconsistencies.

Overloading Props in Polymorphic Components

Polymorphic components like buttons that render as <a> or <button> require flexible typing:

type ButtonProps =
  | ({ as?: "button" } & React.ButtonHTMLAttributes<HTMLButtonElement>)
  | ({ as: "a" } & React.AnchorHTMLAttributes<HTMLAnchorElement>);

function Button({ as = "button", ...rest }: ButtonProps) {
  if (as === "a") return <a {...rest} />;
  return <button {...rest} />;
}

🔁 Clean API for users. Full type safety for you.

Advanced keyof and in for Dynamic UIs

For forms, tables, or editors that need to render based on object keys:

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

type UserKeys = keyof User; // "id" | "name" | "email"

const headers: Record<UserKeys, string> = {
  id: "ID",
  name: "Name",
  email: "Email Address",
};

Use these patterns in:

  • Form builders (fields: Array<keyof Schema>)

  • Table components (columns.map(col => row[col]))

  • Dynamic accessors (object[key as keyof typeof object])

satisfies for Constrained Literals

Keep type inference but enforce constraints. Perfect for config objects:

const config = {
  theme: "dark",
  layout: "grid",
} satisfies Record<string, string>;

Unlike full typing, satisfies doesn’t lose literal values. Great for avoiding casting or inference loss.

Should you use React.FC?

Short answer: probably not — but there’s nuance.

Want a deep dive? Read the full article.

✅ Pros:

  • Automatically types children

  • Slightly shorter syntax for quick scaffolding

❌ Cons:

  • Forces children even when not needed

  • Hides the component’s actual prop type

  • Difficult to use with generics

  • Can lead to confusing inference in larger codebases

Recommended Approach:

type Props = { title: string };
const Card = ({ title }: Props) => <div>{title}</div>;

This is explicit, safer, and more maintainable.

Typescript Mistakes

In this section, we’ll explore common mistakes TypeScript developers make and provide practical solutions to avoid or fix them.

Using any Excessively

Mistake

Overusing any removes type safety, making your code prone to runtime errors.

Bad

let data: any;
data = "Hello";
data = 42; // No type checking

Good

let data: unknown;
data = "Hello";
if (typeof data === "string") {
  console.log(data.toUpperCase()); // Safe to use
}

Ignoring Strict Compiler Options

Mistake

Not enabling strict mode weakens TypeScript’s type checking.

Bad

{
  "compilerOptions": {
    "strict": false
  }
}

Good

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

Not Using Type Inference

Mistake

Explicitly typing everything, even when TypeScript can infer it.

Bad

let count: number = 0;
let name: string = "John";

Good

let count = 0; // TypeScript infers `number`
let name = "John"; // TypeScript infers `string`

Overusing Non-Null Assertions (!)

Mistake

Using ! to assert non-null values without proper checks.

Bad

let element = document.getElementById("myElement")!;
element.click(); // Risky

Good

let element = document.getElementById("myElement");
if (element) {
  element.click(); // Safe
}

Not Handling undefined or null Properly

Mistake

Ignoring potential undefined or null values.

Bad

let name = user.profile.name; // Could throw an error

Good

let name = user?.profile?.name ?? "Default Name"; // Safe

Misusing Enums

Mistake

Using enums when a union type would suffice.

Bad

enum Status {
  Active,
  Inactive,
}

Good

type Status = "active" | "inactive";

Not Leveraging Utility Types

Mistake

Manually creating types when utility types could simplify your code.

Bad

interface User {
  id: number;
  name: string;
  email: string;
}
interface UserPreview {
  id: number;
  name: string;
}

Good

type UserPreview = Pick<User, "id" | "name">;

Ignoring readonly for Immutability

Mistake

Not marking properties as readonly when they shouldn’t change.

Bad

interface Config {
  apiUrl: string;
}
const config: Config = { apiUrl: "https://api.example.com" };
config.apiUrl = "https://malicious.com"; // Mutated

Good

interface Config {
  readonly apiUrl: string;
}
const config: Config = { apiUrl: "https://api.example.com" };
// config.apiUrl = "https://malicious.com"; // Error: Cannot assign to 'apiUrl'

Not Using Generics Effectively

Mistake

Writing repetitive code instead of using generics.

Bad

function identityNumber(num: number): number {
  return num;
}
function identityString(str: string): string {
  return str;
}

Good

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

Ignoring interface vs type Differences

Mistake

Using interface and type interchangeably without understanding their differences.

Bad

type User = {
  name: string;
};
type Admin = User & { role: string }; // Works, but `interface` is better for object shapes

Good

interface User {
  name: string;
}
interface Admin extends User {
  role: string;
}

Not Using as const for Literal Types

Mistake

Not preserving literal types when needed.

Bad

const colors = ["red", "green", "blue"]; // Type: string[]

Good

const colors = ["red", "green", "blue"] as const; // Type: readonly ["red", "green", "blue"]

Not Handling Async Code Properly

Mistake

Forgetting to handle promises or using any for async results.

Bad

function fetchData(): Promise<any> {
  return fetch("/api/data");
}

Good

async function fetchData(): Promise<MyDataType> {
  const response = await fetch("/api/data");
  return response.json();
}

Not Using Type Guards

Mistake

Not narrowing types with type guards.

Bad

function printValue(value: unknown) {
  console.log(value.toUpperCase()); // Error: 'value' is of type 'unknown'
}

Good

function isString(value: unknown): value is string {
  return typeof value === "string";
}
function printValue(value: unknown) {
  if (isString(value)) {
    console.log(value.toUpperCase()); // Safe
  }
}

Ignoring tsconfig.json Settings

Mistake

Not configuring tsconfig.json properly.

Bad

{
  "compilerOptions": {
    "target": "ES5"
  }
}

Good

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}

Not Writing Tests for Types

Mistake

Assuming types are correct without testing them.

Bad

function add(a: number, b: number): number {
  return a + b;
}
// No type tests

Good

import { expectType } from "tsd";
function add(a: number, b: number): number {
  return a + b;
}
expectType<number>(add(1, 2)); // Passes
expectType<string>(add(1, 2)); // Fails

Not Using keyof for Type-Safe Object Keys

Mistake

Accessing object keys without type safety can lead to runtime errors.

Bad

function getValue(obj: any, key: string) {
  return obj[key]; // No type safety
}

Good

function getValue<T, K extends keyof T>(obj: T, key: K) {
  return obj[key]; // Type-safe
}

Ignoring never for Exhaustiveness Checking

Mistake

Not using never to ensure all cases are handled in a union type.

Bad

type Shape = "circle" | "square";
function getArea(shape: Shape) {
  if (shape === "circle") {
    return Math.PI * 2 ** 2;
  }
  // Forgot to handle "square"
}

Good

function getArea(shape: Shape) {
  if (shape === "circle") {
    return Math.PI * 2 ** 2;
  }
  if (shape === "square") {
    return 4 ** 2;
  }
  const _exhaustiveCheck: never = shape; // Ensures all cases are handled
  throw new Error(`Unknown shape: ${shape}`);
}

Not Using Mapped Types

Mistake

Manually creating similar types instead of using mapped types.

Bad

interface User {
  id: number;
  name: string;
  email: string;
}
interface OptionalUser {
  id?: number;
  name?: string;
  email?: string;
}

Good

type OptionalUser = Partial<User>;

Not Using satisfies for Type Validation

Mistake

Not validating that an object satisfies a specific type.

Bad

const user = {
  id: 1,
  name: "John",
  // Missing `email`
};

Good

const user = {
  id: 1,
  name: "John",
  email: "john@example.com",
} satisfies User; // Ensures `user` matches `User` type

Not Using infer in Conditional Types

Mistake

Not leveraging infer to extract types dynamically.

Bad

type GetReturnType<T> = T extends (...args: any[]) => any ? any : never;

Good

type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

Not Using declare for Ambient Declarations

Mistake

Not using declare for external libraries or global variables.

Bad

const $ = jQuery; // No type information

Good

declare const $: typeof jQuery; // Provides type information

Not Using const Assertions for Immutable Arrays/Objects

Mistake

Not using const assertions to make arrays or objects immutable.

Bad

const colors = ["red", "green", "blue"];
colors.push("yellow"); // Allowed

Good

const colors = ["red", "green", "blue"] as const;
// colors.push("yellow"); // Error: Property 'push' does not exist

Not Using this Parameter in Callbacks

Mistake

Not binding this in callbacks, leading to unexpected behavior.

Bad

class Button {
  constructor() {
    this.element.addEventListener("click", this.handleClick);
  }
  handleClick() {
    console.log(this); // `this` is undefined
  }
}

Good

class Button {
  constructor() {
    this.element.addEventListener("click", this.handleClick.bind(this));
  }
  handleClick() {
    console.log(this); // `this` refers to the Button instance
  }
}

Not Using Record for Dictionary-Like Objects

Mistake

Using any or loose types for dictionary-like objects.

Bad

const users: { [key: string]: any } = {
  "1": { name: "John" },
  "2": { name: "Jane" },
};

Good

const users: Record<string, { name: string }> = {
  "1": { name: "John" },
  "2": { name: "Jane" },
};

Not Using Awaited for Unwrapping Promises

Mistake

Not properly unwrapping nested promises.

Bad

type Result = Promise<Promise<string>>; // Nested promises

Good

type Result = Awaited<Promise<Promise<string>>>; // Unwraps to `string`

Not Using unknown for Catch Clauses

Mistake

Using any for catch clauses, which can hide errors.

Bad

try {
  // Some code
} catch (error: any) {
  console.log(error.message); // Unsafe
}

Good

try {
  // Some code
} catch (error: unknown) {
  if (error instanceof Error) {
    console.log(error.message); // Safe
  }
}

Conclusion

I hope that you have found this article helpful and that it has inspired you to become a better Typescript developer.

Happy coding ❤️

References

https://blog.logrocket.com/types-vs-interfaces-typescript

https://dev.to/sachinchaurasiya/type-safety-in-typescript-unknown-vs-any-55c0?context=digest

https://dev.to/zacharylee/the-differences-between-object-and-object-in-typescript-f6f?context=digest

https://itnext.io/mastering-typescript-21-best-practices-for-improved-code-quality-2f7615e1fdc3

https://programming.earthonline.us/what-are-k-t-and-v-in-typescript-generics-9fabe1d0f0f3

https://programming.earthonline.us/with-these-articles-you-will-not-be-confused-when-learning-typescript-d96a5c99e229#16ee

https://javascript.plainenglish.io/5-very-useful-tricks-for-thetypescript-typeof-operator-404c0d30cd5

https://javascript.plainenglish.io/how-to-make-your-typescript-code-more-elegant-73645401b9b1

https://levelup.gitconnected.com/typescript-index-signature-explained-b040a78a0467

https://levelup.gitconnected.com/typescript-type-guards-in-6-minutes-9a9bab7fbe78

https://ramkumarkhub.medium.com/advanced-typescript-patterns-that-will-make-you-a-better-javascript-developer-3accccc3fc78

https://lakin-mohapatra.medium.com/top-30-mistakes-typescript-developers-make-and-how-to-avoid-them-59899f95615a

https://medium.com/web-tech-journals/advanced-typescript-techniques-for-react-developers-write-safer-smarter-components-360ec076125f

https://medium.com/the-syntax-diaries/typescript-types-that-scared-me-until-i-learned-these-3-rules-34f8ea09ecb2

https://www.freecodecamp.org/news/learn-object-oriented-programming-in-typescript/?ref=dailydev

https://medium.com/codetodeploy/time-is-money-the-typescript-patterns-that-tripled-my-productivity-and-will-transform-yours-32088c876c80

https://medium.com/codetodeploy/the-dead-simple-typescript-trick-that-saved-me-15-hours-of-debugging-hell-and-why-youre-probably-a62e3aef8b8b

More from this blog

T

Tuanhadev Blog

30 posts

👋 Hi there, I'm tuanhadev! I am a developer creating open-source projects and writing about web development, side projects, and productivity.