Free discovery callFree discovery call

Practical aspects of advanced TypeScript - part 2

Learn practical stuff for everyday use with Typescript

DevelopmentLast updated: 15 Feb 20247 min read

By Marko Stjepanek

We learned unknown, type guard, union, and intersections in the first part.
If you skipped the first part you could check it out here Practical aspects of advanced TypeScript - part 1. In the order to learn some other advanced topics, in this part, we will learn about generics, conditional types, and, utility types.

Generics

Generics are to types what values are to function arguments — a way to tell our functions, classes, or interfaces what type we want to use when we call it. Similar to how we tell a function what values to use as arguments when we call them.
The great news is that, most likely, you won’t need to create generic functions very often. It’s much more common to call generic functions than to define them. Still, it’s very useful to know how generic functions work, as it can help you understand compiler errors more.

Good use cases for using generics are:

  • When function, class, or interface will work with a different variety of data types
  • When function, class, or interface uses that data on more different places

Functions that take arrays as parameters are often generic functions, so map, filter, forEach, etc. are generics.
For example when you click to get the definition of the map method you get this:

map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[]

Also, for example, most operators in RxJS, and Higher-Order Components in React framework are generic functions.

To learn generics we are going to write “Hello World” example of generics - identity function.
This is a function that returns the input parameter value which is a number type.

function identity (value: number): number {
	return value
}

What if we want the function to return whatever type we gave it? It would look like this:

function identity (value: any): any {
	return value
}

With any, we got that our function can accept any input, but as we said in the first article, we don’t want to use any because we lose type safety. Also, we would lose the information about which type will function return. So to get a function that can get whatever input type without writing any, we use generics.

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

console.log(identity<string>("Hello")) // Hello

In the call of a function Instead of T we pass in the real type that we want to use for that specific function call. <T> is just a syntax for the argument of type. It can also take in multiple types just like we can have multiple arguments in a function.

function identity <T, U>(value: T, msg: U): T {
	console.log(msg)
	return value
}  

Generic Constraints

Sometimes we want to constraint the number of types that our generic accept. Generic constraints exactly do that. For example, we want to use the .length property that not every type has. We are going to use our previous example identity function to show it.

In this case, the compiler doesn’t know that T indeed has a .length property. We can extend our type with an interface that has our length property.

interface Length {
    length: number;
}

function identity <T extends Length>(value: T) : T {
    console.log(value.length)
    return value;
}

When we want to use the identity function it gives us an error if we put a type that doesn’t have the .length property like a number.

Also, constraints are used to check if the key exists in the object. We’d like to be sure that we’re not accidentally using a property that does not exist on the obj.
name and age are keyof Person and if we call this function it would look like this:

interface Person {
    name: string;
    age: number;
}

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const person: Person = {
    name: 'Max',
    age: 25
}

console.log(get(person, 'job')) // Argument of type '"job"' is not assignable to parameter of type 'keyof Person'.
console.log(get(person, 'name')) // Max

Conditional Types

The main advantage of conditional types is their ability to narrow down the possible actual types of a generic type. The syntax for conditional types is similar to ternary expressions and is based on generics.

type Conditional<T> = T extends U ? A : B

Where T is the generic type parameter. U, A, and B are other types.
For example, this is how function overloading would look without conditional types.

interface Order {
    id: number
    items: number
}

interface Customer {
    id: number
    name: string
    surname: string
}
 
interface Product {
    id: number
    quantity: number
 }

function fetchOrder(id: number): Order
function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]

We can use conditional type to simplify our overloads to a single function:

... 
type FetchParams = number | Customer | Product

type FetchReturns<T extends FetchParams> = T extends Customer | Product 
    ? Order[] 
    : Order

function fetchOrder<T extends FetchParams>(params: T): FetchReturns<T>

fetchOrder(customer) // Order[]
fetchOrder(product) // Order[]
fetchOrder(5) // Order
fetchOrder('test') // string is not assignable to parameter of type 'FetchParams'

Utility Types

Utility types are a set of built-in types that allow you to create new types based on the properties of existing types. They are practical for easier creating specific types and help you make more maintainable code.

There are many utility types, but we will go through the most important ones:

Readonly

Creates a new type that has all the properties of the original type, but with all properties marked as read-only. That means that properties of the constructed type cannot be reassigned.

interface Animal {
    name: string
    age: number
}

type ReadonlyAnimal = Readonly<Animal>

const cat: Animal = {
    name: 'Mittens',
    age: 3
}

const dog: ReadonlyAnimal = { 
    name: 'Rex', 
    age: 5 
};

cat.name = 'Tom' // OK
dog.name = 'Fido' // Cannot assign to 'name' because it is a read-only property.

Partial

Creates a new type that has all the properties of the original type, but with all properties marked as optional.

interface Animal {
    name: string
    age: number
}

type PartialAnimal = Partial<Animal>

const cat: Animal = { // Property 'age' is missing in type '{ name: string; }' but required in type 'Animal'.
    name: 'Mittens',
}

const dog: PartialAnimal = { // OK
    name: 'Rex',
}

The problem with Readonly, Partial, and many other utility types is that they only work with the first level of properties. So if you have a nested data structure you will have to create your own deep utility:

type DeepReadonly<T> = {
    readonly [P in keyof T]: DeepReadonly<T[P]>
}
type DeepPartial<T> = {
    [P in keyof T]?: DeepPartial<T[P]>
}

Example with DeepPartial:

interface Student {
    name: string
    age: number
    address: {
        city: string
        street: string
    }
}

type PartialStudent = Partial<Student>

const student: PartialStudent = { // Property 'street' is missing in type '{ city: string; }' but required in type '{ city: string; street: string; }'.
    name: 'Max',
    address: {
        city: 'Berlin'
    }
}

type DeepPartial<T> = {
    [P in keyof T]?: DeepPartial<T[P]>
}

type DeepPartialStudent = DeepPartial<Student>

const newStudent: DeepPartialStudent = { // OK
    name: 'Max',
    address: {
        city: 'Berlin'
    }
}

Required

Creates a new type that has all the properties of the original type, but with all previous optional properties that are now required.

interface Animal {
    name: string
    age?: number
}

type RequiredAnimal = Required<Animal>

const cat: Animal = { // OK
    name: 'Mittens'
}

const dog: RequiredAnimal = { // Property 'age' is missing in type '{ name: string; }' but required in type 'Required<Animal>'.
    name: 'Rex'
}

Record

Shortcut for creating a new type with a set of specific keys and values. This utility can be used to map the properties of a type to another type.

type NumberRecord = Record<string, number>
const nameAgeMap: NumberRecord = {
    'Jake': 21,
    'Bob': 25,
    'Mark': 50
}

Pick

Creates a new type that removes all but the specified keys from an object type.

interface Person {
    name: string;
    age: number;
    country: string
    address: string;
}

type PersonMainInfo = Pick<Person, 'name' | 'age'>
  
const user: PersonMainInfo= {
    name: 'Bob',
    age: 25
}

Omit

This works the other way around than Pick. It creates a new type that has all the properties of the original type, except for a set of specific properties which will be removed.

interface Person {
    name: string;
    age: number;
    country: string
    address: string;
}

type PersonMainInfo = Omit<Person, 'address'>
  
const user: PersonMainInfo= {
    name: 'Bob',
    age: 25,
    country: 'USA'
}

Exclude

Creates a new type that excludes types from a union of the original type.

type actions = 'add' | 'remove' | 'update' | 'delete'

type basicActions = Exclude<actions, 'update' | 'delete'> // type basicActions = "add" | "remove"

const btnAction: basicActions = 'add'

Extract

Creates a new type that extracts types from a union of the original type. Reverse utility of Exclude.

type actions = 'add' | 'remove' | 'update' | 'delete'

type basicActions = Extract<actions, 'add' | 'remove'> // type basicActions = "add" | "remove"

const btnAction: basicActions = 'remove'

NonNullable

Creates a new type that removes the null and undefined values from the original type.

type actions = 'add' | 'remove' | 'update' | 'delete' | null

type NonNullableActions = NonNullable<actions> // type NonNullableActions = "add" | "remove" | "update" | "delete"

Conclusion

In conclusion, generics, conditional types, and utility types are powerful tools in the TypeScript language that allows developers to write code that is more flexible, reusable, and expressive. Generics allow developers to create functions and classes that can work with a variety of types, rather than being tied to a specific type. Conditional types allow developers to create type definitions that depend on other types, allowing for more dynamic and expressive type definitions. Utility types provide a set of pre-defined types and type transformations that can be used to create more complex and expressive type definitions.

Overall, these features make it easier for developers to write type-safe code that is easy to understand and maintain. They are a valuable addition to the TypeScript language and can greatly improve the developer experience when working with complex and dynamic data structures.

Thank you for your time to read this blog! Feel free to share your thoughts about this topic and drop us an email at hello@prototyp.digital.

Related ArticlesTechnology x Design

View all articlesView all articles
( 01 )Get started

Start a Project