Advanced TypeScript Types Cheat Sheet (with Examples)

TypeScript‘s advanced type system is one of its most powerful features, enabling developers to write cleaner, more maintainable code. However, with so many advanced types available, it can be challenging to remember the syntax and use cases for each one.

In this cheat sheet, we‘ll cover all the essential advanced TypeScript types with clear explanations and practical examples. Whether you need a quick reference or want to deepen your understanding of TypeScript‘s capabilities, this guide has you covered.

Let‘s dive in!

Intersection Types

Intersection types allow you to combine multiple types into one, giving you the flexibility to define entities that share characteristics from different sources. Think of it like a Venn diagram, where the intersection type contains only the common properties.

Intersection type diagram

Here‘s the basic syntax:

type Foo = {
  a: string;
  b: number; 
}

type Bar = {
  b: number;
  c: boolean;
}

type FooBar = Foo & Bar;

let variable: FooBar = {
  a: ‘hello‘,
  b: 42,
  c: true
}

In this example, FooBar is an intersection of the Foo and Bar types. Any variable of type FooBar must have all the properties from both constituent types.

Intersection types are useful when you need to merge configuration options or share fields between different entities. For instance, say you have a User type and an Account type:

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

type Account = {
  id: string;
  balance: number;
}

type UserAccount = User & Account;

Now the UserAccount type has all properties of both User and Account, enabling you to define variables that are both a user and an account.

Union Types

Where intersection types require shared properties, union types allow a value to be one of several types. They are defined using the vertical bar (|) character.

type Status = ‘active‘ | ‘inactive‘;

function setStatus(status: Status) {
  // ...
}

setStatus(‘active‘); // valid
setStatus(‘pending‘); // error

Here, the Status type can only be ‘active‘ or ‘inactive‘, enforcing valid values at compile time.

Union types are handy for defining possible string literal values, like in the example above, but they also work with other types:

type Foo = string | number;

function bar(foo: Foo) {
  // ...  
}

bar(‘hello‘); // valid
bar(42); // valid 
bar(true); // error

In this case, bar accepts either a string or number parameter thanks to the Foo union type.

Generic Types

Generics allow you to write reusable code that works with multiple types rather than a single one. They are essentially a way to parameterize types, similar to how functions parameterize values.

The syntax for generics uses angle brackets (<>) to enclose the type parameter:

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

let foo = identity<string>(‘hello‘);
let bar = identity<number>(42);

The identity function takes a type parameter T and an argument of that same type, then returns a value of that type. When calling identity, you specify the concrete type in angle brackets, locking in T for that invocation.

Generics are invaluable for writing utility functions that deal with collections or promise results:

async function fetchData<T>(url: string): Promise<T> {
  const response = await fetch(url);
  return response.json();
}

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

fetchData<User>(‘/api/users/1‘)
  .then(user => {
    console.log(user.name);
  });

By making fetchData generic, we can specify the expected return type when calling it, giving us type safety and autocompletion without needing to manually assert the result type.

Utility Types

TypeScript provides several utility types that facilitate common type transformations. These types are globally available and typically start with a capital letter. Let‘s examine a few of the most frequently used ones.

Partial

Partial<T> transforms all properties of T into optional properties, meaning they can be omitted.

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

type PartialUser = Partial<User>;

// Valid
const partialUser: PartialUser = {
  name: ‘John‘
};

This is useful when you need to represent subsets of data or define configuration objects with default values.

Required

Required<T> does the opposite of Partial, making all properties of T required.

type User = {
  name?: string;
  email?: string;
}

type RequiredUser = Required<User>;

// Error: email is missing
const reqUser: RequiredUser = {
  name: ‘John‘ 
};

Readonly

Readonly<T> makes all properties of T read-only, preventing reassignment.

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

type ReadonlyUser = Readonly<User>;

const user: ReadonlyUser = {
  name: ‘John‘,
  email: ‘[email protected]‘
};

user.name = ‘Johnny‘; // Error: name is read-only

This protects objects from unintended mutation and can serve as a performance optimization hint to JavaScript engines.

Pick

Pick<T, K> constructs a new type by picking a set of properties K from T.

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

type NameAndEmail = Pick<User, ‘name‘ | ‘email‘>;

const nameAndEmail: NameAndEmail = {
  name: ‘John‘,
  email: ‘[email protected]‘
};

This is handy for slicing off parts of existing types when you don‘t need all properties.

Omit

Omit<T, K> is the complement to Pick, creating a new type with properties K removed from T.

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

type UserWithoutAge = Omit<User, ‘age‘>;

const userWithoutAge: UserWithoutAge = {
  name: ‘John‘,
  email: ‘[email protected]‘ 
};

This lets you exclude irrelevant or sensitive properties from types.

Mapped Types

Mapped types build on the syntax for index signatures, which are used to declare the types of properties that have not been declared ahead of time:

type Foo = {
  [key: string]: number;
}

Here, Foo has a string index signature, meaning Foo is a type with any number of properties of type number indexed by a string.

Mapped types allow you to create new types based on old ones by transforming each property in the original type:

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

type User = {
  name: string;
  age: number;
}

type StringifiedUser = Stringify<User>;
// same as
// type StringifiedUser = {
//   name: string;
//   age: string;  
// }

The mapped type Stringify<T> takes each property of T and transforms its type to string. You can also leverage mapped types to make properties optional or read-only:

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

type Readonly<T> = {
   readonly [K in keyof T]: T[K];
}

These examples implement the Partial and Readonly utility types discussed earlier. Mapped types open up a world of possibilities for generating new types based on existing ones.

Type Guards

Type guards are expressions that perform a runtime check to narrow down, or constrain, the type of a variable within a conditional block. There are several ways to define a type guard in TypeScript.

typeof

The typeof type guard checks the type of a variable against a string literal:

function foo(bar: string | number) {
  if (typeof bar === ‘string‘) {
    // bar is a string here
    return bar.toUpperCase();
  } else {
    // bar is a number here
    return bar.toFixed(2);
  }
}

Inside the if block, TypeScript knows bar is a string so we can call string methods like toUpperCase. In the else block, bar is narrowed to a number.

instanceof

The instanceof type guard checks whether a value is an instance of a particular constructor function:

class Foo {
  foo = ‘foo‘;
}

class Bar {
  bar = ‘bar‘;
}

function baz(arg: Foo | Bar) {
  if (arg instanceof Foo) {
    console.log(arg.foo); // OK
    console.log(arg.bar); // Error: Property ‘bar‘ does not exist on type ‘Foo‘
  } else {
    console.log(arg.bar); // OK  
    console.log(arg.foo); // Error: Property ‘foo‘ does not exist on type ‘Bar‘
  }
}

The instanceof guard lets TypeScript narrow arg to either the Foo or Bar class inside the conditional blocks.

in

The in type guard checks for the existence of a property on an object:

interface Foo {
  foo: string;
}

interface Bar {
  bar: string;
}

function baz(arg: Foo | Bar) {
  if (‘foo‘ in arg) {
    console.log(arg.foo); // OK
    console.log(arg.bar); // Error
  } else {
    console.log(arg.bar); // OK
    console.log(arg.foo); // Error  
  }
}

Similar to instanceof, the in guard narrows arg to Foo or Bar based on whether it has a ‘foo‘ property.

Type guards enable powerful control flow analysis that lets you write expressive conditional logic operating on TypeScript‘s static types.

Conditional Types

Conditional types introduce the ability for a type to make a decision based on another type. They take the form T extends U ? X : Y, meaning "if T is assignable to U, return type X, otherwise return type Y".

Here‘s a simple example:

type IsNumber<T> = T extends number ? ‘yes‘ : ‘no‘;

type Foo = IsNumber<42>; // ‘yes‘
type Bar = IsNumber<‘hello‘>; // ‘no‘

The IsNumber conditional type checks if its type parameter T is assignable to number. If so, it returns the type ‘yes‘, else it returns ‘no‘.

Conditional types become especially powerful when combined with generics and mapped types:

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

type Foo = ElementType<number[]>; // number
type Bar = ElementType<string>; // string

Here, ElementType<T> checks if T is an array type using an infer declaration to extract the element type U. If T is an array, it returns U, else it just returns T.

Conditional types give you a way to express complex type relationships and transformations that can adapt to the input type. They are a key building block for many advanced TypeScript techniques.

Conclusion

TypeScript‘s rich type system provides an array of tools for modeling your application domain and catching errors at compile time. By leveraging advanced types like intersections, unions, generics, and mapped types, you can create expressive and reusable type definitions.

This cheat sheet has covered the key concepts you need to know, along with practical examples and use cases. Bookmark it as a handy reference, and don‘t be afraid to experiment with combining different types to capture your unique constraints.

As you dive deeper into TypeScript, keep exploring its type system and push the boundaries of what‘s possible. You may be surprised at just how much complexity you can model and how many errors you can catch before they ever make it to runtime. Happy typing!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *