Contact
Stories

Development

5 min read

Typescript: Tips and Tricks for Improving Your Coding skills [Part 2]

Mihael
Mihael
Web Development

Welcome to part two of the article, I hope you are ready for more coding tips. Today I will show you how to make types change depending on some conditional types and explain how to work with type unions to leverage things you can do with them.

1. Conditional types

Conditional types allow you, as their name implies, to conditionally determine which type should something be.

I will get to the example in just a bit, but let me first introduce you to the never type in Typescript. The never type is simple to explain: use it for situations you never want to happen - that’s it. So, the code I will show you creates a generic type that will make your value always truthy. If the compiler cannot ensure that the value is truthy, it won’t compile.

type Truthy<T> = T extends 0 | "" | false | null | undefined ? never : T;

// Bonus: you can use generics in functions
function takesInOnlyTruthy<T>(input: Truthy<T>) {}

// Passes compilation
// Bonus: most of the times, compiler can infer the “T”, like here
// no need to write takesInOnlyTruthy<string>('Foo bar')
takesInOnlyTruthy("Foo bar");

// Does not pass compilation
takesInOnlyTruthy(undefined);

As you can see, it is easy to write conditional types. The syntax is like a ternary operator in regular Javascript. So what can we do in this example? If T is a falsy value (zero, empty string, false, null, or undefined), the conditional type will result in a never. If it is not a falsy value, it will be whatever the T is.

So, if you try to run the code you will get a compilation error due to the second call of takesInOnlyTruthy. Because our Truthy type returns never if the value is falsey, such as undefined compiler will complain, you will probably get this message: Argument of type 'undefined' is not assignable to parameter of type 'never'. Just like I said, never is for situations you never want to happen. You never want to pass undefined if a function requires a Truthy.

1.1 Conditionals as a return type

You can use conditionals to determine the return type of a function. The only downside is that you must use “as” casting, which can make the code less safe, therefore my advice is to use this only on simple functions.

In the following example, you can see a method that will take only strings and numbers as input. If the string is provided, it will return a string. However, if a number is provided it will return undefined.

// We use "extends"" here to constrain input to only strings and numbers, but we could omit that and allow any type as input
function returnBackOnlyIfString<T extends string | number>(
 input: T
): T extends string ? string : undefined {
 if (typeof input === "string") {
   // Unfortunately, we must use "as" here
   return "Foo bar" as T extends string ? string : undefined;
 }

 // Here too
 return undefined as T extends string ? string : undefined;
}

const fizz = returnBackOnlyIfString("Fizz buzz"); // typeof === 'string'

const baz = returnBackOnlyIfString(5); // typeof === 'undefined'

1.2 Conditionals for determining function arguments

You can also use conditionals to tell what function arguments are supposed to be. Let me show you an example if you want to create a function that takes two arguments. If the first argument is a string, then the second argument must be a number, otherwise, the second argument must be a boolean:

function withConditionalArguments<T>(
 foo: T,
 bar: T extends string ? number : boolean
) {}

// Passes compilation
withConditionalArguments("some string", 123);

// Also passes compilation
withConditionalArguments(null, true);

// Also passes compilation
withConditionalArguments(123, true);

// Won't compile
withConditionalArguments("some string", true);

// Also won't compile
withConditionalArguments(123, 456);

1.3 Use tuples to create optional arguments with conditionals

So, you can define what function arguments should be depending on just one type, but what if we want a different number of arguments (or maybe even a different order)? You can do that using tuples. For those who don't know, they're arrays with known sizes and contents.

It works because in Javascript you can access function arguments via the arguments keyword:

function c() {
 console.log(arguments);
}
c(1, 2, 3);
// If you type this in console of browser it logs something like:  “Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]”

Arguments are array-like objects, but most importantly, they implement an iterator. Therefore we can spread them directly inside a function and make an array out of them.

function c(...args) {
 console.log(args);
}
c(1, 2, 3);
// If you this in console of browser it logs something like: “(3) [1, 2, 3]”

So how does this help us? As I said, tuples are like arrays, but they are arrays under all, considering that Typescript is doing extra checks. In the end, once transpired to plain Javascript, they are just the same old arrays.

With more “spread” magic, we can get function arguments as we like. For example, let’s write a function called “print” that will take one argument called “input” which can be a string or number. I’ll name the second argument “casing” - it can be either “upperCase”, “lowerCase”, “default”. We will require a second argument only if the string is given for input.
Something like this:

function print<T extends string | number>(...[input, casing]: T extends string ? [T, 'upperCase' | 'lowerCase' | 'default'] : [T]) {}

// Passes compilation
print('Foo', 'upperCase')

// Passes compilation
print(123)

// Does not pass compilation
print('Foo')

// Does not pass compilation
print(123, 'upperCase')

It might look complicated but fear not, there is a simpler way to do this by using discriminating unions (it will be explained later in the article).

1.4 Watchout - conditionals do the type distribution

What does Watchout mean? It means that if you define an array like this:

(A | B)[]

distribution converts them to this:

A[] | B[]

Why can this be dangerous? Look at this code sample:


type Foo = { bars: string[] };
type Bar = { foos: string[] };

type Input<T> = {
 data: T;
} & (T extends Foo ? { caller: (input: T) => string[] } : { caller?: never });

const bar: Bar = { foos: ["something", "also something"] };

// This will pass compilation
const x: Input<Foo | Bar> = {
 data: bar,
 caller: (_) => [],
};

Do you see the issue? No? Okay, let me help you. The goal here was to make sure that if the Foo type is given, we want to have the caller property. In case we pass in Bar, we don’t want it. In this example, we have the wrong property - we passed the Bar type, yet Typescript allows us to define a caller. It can be dangerous; caller property could be called with wrong data or when not existing depending on how you set up your code. This all could happen because of type distribution. How can you overcome this problem? Use tuples to disable the distributive behavior of conditionals.

type Foo = { bars: string[] };
type Bar = { foos: string[] };

type Input<T> = {
 data: T;
 // Next line is where changes happen
} & ([T] extends [Foo]
 ? { caller: (input: T) => string[] }
 : { caller?: never });

const bar: Bar = { foos: ["something", "also something"] };

// This won’t compile now
const x: Input<Foo | Bar> = {
 data: bar,
 caller: (_) => [],
};

2. Leveraging unions

2.1 Other possible solutions

Remember how I earlier said that there is a simpler way to achieve different numbers (and types) of function arguments? Well, here it is. The idea is that you use Typescript unions to do that and the easiest way to understand it is by going back to the previous example. It was the one where we were building a function that prints input (which can be string or number) and requires a second argument which can be “upperCase”, “lowerCase” or “default” only if the input is a string. Take a look at this code:

function print(
 ...[input, casing]: [string, "upperCase" | "lowerCase" | "default"] | [number]
) {}

// Passes compilation
print("Foo", "upperCase");

// Passes compilation
print(123);

// Does not pass compilation
print("Foo");

// Does not pass compilation
print(123, "upperCase");

It works because Typescript is smart enough to figure out that if the first argument is a string, we want to “take” the [string, 'upperCase' | 'lowerCase' | 'default'] as a type and if it is a number then we only want [number].
Of course, this approach is not limited only to function arguments, you can use it in other places too (Watchout: just make sure to wrap union in brackets after &):

type User = {
 firstName: string;
 lastName: string;
} & (
 | { isFullMember: true; membershipType: string }
 | { isFullMember: false; isFullMembershipRequested: boolean }
);

// Passes compilation
const user: User = {
 firstName: "John",
 lastName: "Doe",
 isFullMember: true,
 membershipType: "Platinum",
};

// Wont pass compilation
const user2: User = {
 firstName: "John",
 lastName: "Doe",
 isFullMember: false,
 membershipType: "Platinum", // this field gives errors now
};

// Passes compilation
const user3: User = {
 firstName: "John",
 lastName: "Doe",
 isFullMember: false,
 isFullMembershipRequested: false, // but now this is ok
};

Notice that sometimes when working with TSX (React) and using this in Props, you can come across a situation where the compiler would complain that not all fields are given (for example, requiring  membershipType even though isFullMember is false). Doing so would give the error we encountered with the user2 of the above example. Solution? Make all properties present in both cases, but make them optional and set their type to never:

type User = {
 firstName: string;
 lastName: string;
} & (
 | {
     isFullMember: true;
     membershipType: string;
     isFullMembershipRequested?: never;
   }
 | {
     isFullMember: false;
     isFullMembershipRequested: boolean;
     membershipType?: never;
   }
);

2.2 Leverage unions when using generics

You can use unions to “write out” some of the types. Take a look at this situation:

function takesInOnlynumbers(input: number[]) {}

// Error: Type 'number | boolean | null | undefined' is not assignable to type 'number'
takesInOnlynumbers([1, 2, 3, 4, null, undefined, false, 5].filter((x) => !!x));

We know that after filtering, the array will only have numbers, but Typescript does not. So how do we fix this? Well, we can use unions to do that. Let's just write the signature of the function:

function takesInOnlynumbers(input: number[]) {}

function removeFalsy<T>(input: (T | null | undefined | false | "" | 0)[]): T[] {
 return [];
}

// This will now pass compilation.
takesInOnlynumbers(removeFalsy([1, 2, 3, 4, null, undefined, false, 5]));

We added a new function called removeFalsy. It takes a type T, but we defined input as an array of T | null | undefined | false | ‘’ | 0 and that is what I meant by we can “write out” some types. So, we said: “okay, I do not know what exactly T is, but I do not want it to be null | undefined | false | ‘’ | 0. And by doing this we have “distanced” T from those other types/values. In the end, we need to say that our function returns the array of the only T by signing that with T[].

After that, we need to make this function work. Now, I’ll introduce you to the so-called type predicates and user-defined type guards. Simply said, it is a function that tells whether some input is some type.

interface User {
 name: string;
}

function isUser(input: any): input is User {
 return "name" in input;
}

Now let’s use it in our function. Here is the full code:

function removeFalsy<T>(input: (T | null | undefined | false | "" | 0)[]): T[] {
 return input.filter((x): x is T => !!x);
}

takesInOnlynumbers(removeFalsy([1, 2, 3, 4, null, undefined, false, 5]));

That’s it.
There is your gotcha moment. If you do this, the compilation won’t pass.

const arr = [1, 2, 3, 4, null, undefined, false, 5];

// This won’t pass compilation
takesInOnlynumbers(removeFalsy(arr));

This happens because Typescript cannot ensure the type of array won’t change. We could also do this:

const arr = [1, 2, 3, 4, null, undefined, false, 5];
arr.push(true);

takesInOnlynumbers(removeFalsy(arr));

Since arr is initially false, the compiler considers that as a boolean. Therefore we can add true to the array. So in the end, removeFalsy would return an array of numbers and booleans. That is why Typescript does not allow that to compile.
Pro tip: you can use the as const keyword to “lock” the array. That makes the array completely unchangeable and works also for objects.

const arr = [1, 2, 3, 4, null, undefined, false, 5] as const;

arr.push(true); // Property 'push' does not exist on type 'readonly [1, 2, 3, 4, null, undefined, false, 5]'.

const obj = {
 foo: "bar",
} as const;

obj.foo = "xyz"; // Cannot assign to 'foo' because it is a read-only property

Doing this would ensure the compiler that the array will stay the same as it is. But you will need to adjust removeFalsy to work with read-only arrays too (there is no real difference in this case and no need for this, but you have to satisfy the compiler).

And that’s a wrap!

I hope you learned something new. If you are still new to Typescript, I hope you master it soon and then come back to this article. Maybe you even get an epiphany -  “oh yes, that’s what that guy was talking about ''.  And if you already know all of this, at least I refreshed your memory a bit. You know what they say, Repetitio est mater studiorum. 

Like what you just read?

Go on, spread the news!

About the author

Mihael is a Frontend Web developer at COBE. When he’s not mastering his frontend skills in React and TypeScript, he's either reading books or watching movies.

Mihael

Web Development

Write
Mihael
Write COBE
Related Stories

This was interesting to you? Check these out.