Practical aspects of advanced TypeScript - part 2
Learn practical stuff for everyday use with Typescript
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.