DEV Community

Cover image for Better types in TypeScript with type guards and mapping types
Patryk Andrzejewski for Vue Storefront

Posted on

Better types in TypeScript with type guards and mapping types

TypeScript gains more and more popularity among the Javascript developers, becoming even a stardard when it comes to nowadays software development and replacing to some extent Javascript itself.

Desipte the main goal of this language is to provide type-safety programing in to the chaotic Javascript, many people use it just because that’s the trend. In that case the only feature they use is revealing the types of given values, and if they can’t type something, an any is being used instantly.

Well… TypeScript is so much more. It provides many features, so let’s focus on the ones that will help you with type organizing as well as bringing more security to your code.

A brief story of any

If you used TypeScript, it’s likely that you have been using any so much. This type is quite uncertain one and can mean... everything.. literally everything.

When you type something by any is same as you would say “I don’t care about what the type is here”, so you essentially ignore the typing here as if you were use plain Javascript.

For that reason any should not (almost) never been used, because you ignore the typing, the thing that TypeScript was actually built for!

You may raise a question “ok, but what if I totally don’t know what the type is?!”. Yeah, in some cases you really don’t know it, and for that is better to use unknow over the any.

The unknow type is very similar to any - also match to everything, except one thing - is type-safe. Considering an example:

let x: unknown = 5;
let y: any = 5;

// Type 'unknown' is not assignable to type 'string'.
let x1: string = x;

// fine.
let y1: string = y; 
Enter fullscreen mode Exit fullscreen mode

As you can see, when you use unknown in the context of string, the TypeScript doesn’t allow me to do this, because they are different types, while with any I can do whatever I want.

That’s why any is very insecure. Using any makes your code prone to even crash as you are using one data in the context of different.

Does it mean I can’t use any? No, any has its own purpose, I will show you later. In terms of typing function arguments, return values, type aliases etc. - stay with unknown.

Protection with type guards

This is really important feature of TypeScript. It allows you to check types in your code to assure that your data-flow relies on the correct data types. Many people use it, without even knowing that it’s named “type guards”. Let’s go with examples.

function product(x: number) {}
function discount(x: string) {}

function cart(x: string | number) {
 // Argument of type 'string | number' is not assignable to parameter of type 'number'.
 product(x);
 // Argument of type 'string | number' is not assignable to parameter of type 'number'.
 discount(x);
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here? We have function cart that takes one argument which can be either string or number. Then we call two functions, each requires also one argument, first (product) number second (discount) string. For both functions, the argument from cart has been used - why does TypeScript raise an error?

Well, TypeScript basically has no clue what you want to do. We are giving string or number then use it in a different contexts - once just number then just string. What if you pass string to the function product? Is that correct? Obviously not - it requires a different type. The same with function discount. That’s the issue here.

We must sift somehow possible types, to make sure, we have the right one in the given context. This is the goal of type guards - we make protection in given line against passing incorrect types.

typeof checking

In this particular case, a typeof guard is completely enough:

function cart(x: string | number) {
 if (typeof x === 'number') {
  product(x);
 }
 if (typeof x === 'string') {
  discount(x);
 }
}
Enter fullscreen mode Exit fullscreen mode

Now, everything receives the correct types. Worth notice, if we put return statement inside of first “if” then second if is no longer needed! TypeScript will catch the only one possibility is there.

The object complexity

How about more complex types? What if we have something more sophisticated than primitives?

type Product = {
 qty: number;
 price: number;
}

type Discount = {
  amount: number;
}

function product(x: Product) {}
function discount(x: Discount) {}

function cart(x: Product | Discount) {
 // Argument of type 'Product | Discount' is not assignable to parameter of type 'Product'.
 product(x);
 // Argument of type 'Product | Discount' is not assignable to parameter of type 'Product'.
 discount(x);
}
Enter fullscreen mode Exit fullscreen mode

We have here the same scenario as in the previous example, but this time we have used more complex types. How to narrow down them?

To distinguish "which is which" we can use in operator and check whether the certain fields are present in the object.

For instance, our Product has price while the Discount has amount - we can use it as differentiator.

function cart(x: Product | Discount) {
 if ('price' in x) {
  product(x);
 }

 if ('amount' in x) {
  discount(x);
 }
}
Enter fullscreen mode Exit fullscreen mode

Now, again TypeScript is satisfied, however, is that clean enough?

Customized type guards

A previous solution may solve the problem and works pretty well… as long as you not emerge more complex types - having sophisticated in clause won’t be so meaningful - so what can we do?

TypeScript provides a is operator that allows you to implement special kind of function you can use as type guard.

function isProduct(x: Product | Discount): x is Product {
 return 'price' in x;
}

function isDiscount(x: Product | Discount): x is Discount {
 return 'amount' in x;
}

function cart(x: Product | Discount) {
 if (isProduct(x)) {
  product(x);
 }

 if (isDiscount(x)) {
  discount(x);
 }
}
Enter fullscreen mode Exit fullscreen mode

Look at the example above. We could create a checker-functions that bring capability to confirm the input type is what we expect.

We use statement of is to define, a function which returns boolean value that holds the information if the given argument acts as our type or not.

By using customised type-guards, we can also test them separately and our code becomes more clear and readable.

The configuration is tough…

Agree. The configuration of TypeScript is also quite complex. The amount of available options in a tsconfig.json is overwhelming.

However there are bunch of them that commits to the good practices and quality of the produced code:

  • *strict *- strict mode, I would say that’s supposed to be oblibatory always, it forces to type everything
  • *noImplicitAny *- by default, if there is no value specified, the any type is assigned, This option forces you to type those places and not leave any (eg. function arguments)
  • *strictNullChecks *- the null and undefined are different values, you should keep that in mind, so this option strictly checks this
  • *strictFunctionTypes *- more accurate type checking when it comes to function typings

Obviously there are more, but I think those ones are the most important in terms of type-checking.

More types? Too complex.

Once you project grow, you can reach vast amount of types. Essentially, there is nothing bad with that, except cases when one type was created as copy of the other one just because you needed small changes.

type User = {
 username: string;
 password: string;
}

// the same type but with readonly params
type ReadOnlyUser = {
 readonly username: string;
 readonly password: string;
}
Enter fullscreen mode Exit fullscreen mode

Those cases break the DRY policy as you are repeating the code you have created. So is there any different way? Yes - mapping types.

The mapping types are build for creating new types from the existing ones. They are like regular functions where you take the input argument and produce a value, but in the declarative way: a function is generic type and its param is a function param. Everything that you assign to that type is a value:

type User = {
 username: string;
 password: string;
}

// T is an "argument" here
type ReadOnly<T> = {
 readonly [K in keyof T]: T[K]
}
type ReadOnlyUser = ReadOnly<User>
Enter fullscreen mode Exit fullscreen mode

In the example above, we created a mapping type ReadOnly that takes any type as argument and produces the same type, but each property becomes readonly. In the standard library of TypeScript we can find utilities which are built in exactly that way - using mapping types.

In order to better understand the mapping types, we need to define operations that you can do on types.

keyof

When you use a keyof it actually means “give me a union of types of the object key’s”. For more detailed information i refer to the official documentation, but for the matter of mapping types when we call:

[K in keyof T]
Enter fullscreen mode Exit fullscreen mode

We access the “keys” in the object T, where each key stays under the parameter K - Sort of iteration, but in the declarative way as K keeps the (union) type of keys, not a single value.

As next, knowing that K has types of each parameter in a given object, accessing it by T[K] seems to be correct as we access the “value” that lives under the given key, where this key also comes from the same object. Connecting those statements together:

[K in keyof T]: T[K]
Enter fullscreen mode Exit fullscreen mode

We can define it: “go over the parameters of the given object T, access and return the value that type T holds under given key”. Now we can do anything we want with it - add readonly, remove readonly, add optional, remove optional and more.

The “if” statements

Let’s assume other example:

type Product = {
 name: string;
 price: number;
 version: number; // make string
 versionType: number; // make string
 qty: number;
}

// ???
type VersioningString<T> = T;
type X = VersioningString<Product>
Enter fullscreen mode Exit fullscreen mode

We have type Product and we want to create another type that will change some properties to string, let's say the ones related to version: version and versionType.

We know how to “iterate” but we don’t know how to “make a if”.

type VersioningString<T> = {
 [K in keyof T]: K extends "version" | "versionType" ? string : T[K]
};
Enter fullscreen mode Exit fullscreen mode

We can put the “if” statements in that way by using extend keyword. Since that’s declarative programming, we operate on the types we are checking if our K type extends… the union type of “version” and “versionType” - makes sense? In this meaning we check the inheritance of given type, just like among the classes in oriented programming.

Type inferencing

TypeScript always tries to reveal the types automatically and we can access it and take advantage of revealed type.

It’s quite handy when when it comes to matching something by extend keyword and obtain the inferenced type at the same time.


type ReturnValue<X> = X extends (...args: any) => infer X ? X : never;

type X1 = ReturnValue<(a: number, b: string) => string> // string
Enter fullscreen mode Exit fullscreen mode

This is classical example of obtaining the return type of given function. As you can see, by using extend we can check whether input arg (generic) is a function by its signature, but in that signature we also use infer keyword to obtain of what return type is, then save it under the X field.


Connecting all of pieces together - A real world scenario

Using those mechanics, let’s now break down the following type:

type CartModel = {
 priceTotal: number;
 addToCart: (id: number) => void
 removeFromCart: (id: number) => void
 version: number;
 versionType: number;
}
Enter fullscreen mode Exit fullscreen mode

Our goal is to create a new type that, skips fields related to versioning and adds quantity argument to both addToCart and removeFromCart methods. How?

Since there is no simple declarative operations of skipping fields, we need to implement it in the other way. We know that it's feasible to create a new type from existing one by going over the fields of it, however we exactly want to limit those fields.

type SingleCart <T> = {
  // keyof T ??
  [K in keyof T]: T[K]
}

// prints all fields as normal
type Result = SingleCart<CartModel>

// goal:
type SingleCart <T> = {
  [K in <LIMITED VERSION OF FIELDS OF T>]: T[K]
}
Enter fullscreen mode Exit fullscreen mode

How can we achieve that? Normally to access all of the fields we use keyof T but we our goal is to limit te list of possible keys of T.

Since the keyof T gives us a union of the fields, we can limit this by using extend keyword:

// access all of the fields
type R1 = keyof CartModel

type SkipVersioning <T> = T extends "version" | "versionType" ? never : T

// gives union of "priceTotal" | "addToCart" | "removeFromCart"
type R2 = SkipVersioning<keyof CartModel>
Enter fullscreen mode Exit fullscreen mode

So now we can use that type:

type SkipVersioning <T> = T extends "version" | "versionType" ? never : T

type SingleCart <T> = {
  [K in SkipVersioning<keyof T>]: T[K]
}

/*
it gives a type: 
type ResultType = {
   priceTotal: number;
   addToCart: (id: number) => void;
   removeFromCart: (id: number) => void;
}
*/
type Result = SingleCart<CartModel>
Enter fullscreen mode Exit fullscreen mode

We have just removed fields related to the version!

The next part is adding a quantity argument to functions in the type. As we already have access to the type of given field (T[K]), we need to introduce another one for transforming if given type is function:

type AddQuantityToFn<T> = ... // ??

type SingleCart <T> = {
  [K in SkipVersioning<keyof T>]: AddQuantityToFn<T[K]>
}
Enter fullscreen mode Exit fullscreen mode

The T[K] is being wrapped by a new type AddQuantityToFn. This type needs to check whether given type is a function and if that's true, add to this function a new argument quantity if not, don't do anything. The implementation may look as follows:

type AddQuantityToFn <T> = T extends (...args: infer A) => void ?
  (quantity: number, ...args: A) => void
  :
    T
Enter fullscreen mode Exit fullscreen mode

If the type is a function (extends (...args: infer A) => void), add a new argument quantity: number (returns a type of (quantity: number, ...args: A) => void) if not, keep the old type T. Please notice we are using also type inferencing (infer A) to grab the old argument's types.

Below, full implementation of it:

// Skips properties by given union
type SkipVersioning <T> = T extends "version" | "versionType" ? never : T

// Adds new argument to the function
type AddQuantityToFn <T> = T extends (...args: infer A) => void ?
 (quantity: number, ...args: A) => void
 : T

// Goes over the keys (without versioning ones) and adds arguments of quantity if that's method.
type SingleCart <T> = {
 [K in SkipVersioning<keyof T>]: AddQuantityToFn<T[K]>
}

type ResultType = SingleCart<CartModel>
Enter fullscreen mode Exit fullscreen mode

Quick summary: First of, we have defined a type that generates for us a union of property names besides ones related to versioning. Secondly, type for creating a new argument - if the type if function - if not, return given type. Lastly, our final type that goes over the keys (filtered) of an object, and adds arguments to the method (if needed).

Recap

TypeScript might be difficult and helpful at the same time. The most important thing is to start using types in a wise way with an understanding of how they work and with a right configuration that will lead you to produce properly typed code.

If that's something overwhelming for newcomers, would be nice to introduce it gradually and carefully and in each iteration provide better and better typings as well as type guarding of your conditional statements.

Top comments (2)

Collapse
 
vietphong profile image
vietphong

thanks so much!

Collapse
 
chamarapw profile image
ChamaraPW

Very helpful post