TypeScript Narrowing #6
See this and many other articles at lucaspaganini.com
Oofff, it’s part 6 already! I wonder how many of you are reading this since the beginning.
Today we'll grab a pattern from the functional programming world known as "Higher Order Functions" and use it to create functions that receive functions and return new functions.
function f1() {}
function f1(f2: Function) {}
But we won't stop there. We won't just return any new functions, more precisely, we will return new custom type guards! So, I'm calling those guard creation functions "Higher Order Guards".
const makeIsNot = fn => ✨magic✨
As you'll soon find out, that will open the door for new possibilities of reusing our code.
const makeIsNot = fn => ✨magic✨
const isNotString = makeIsNot(isString)
let aaa = 'abc' as string | number | boolean
if (isNotString(aaa)) {
aaa // <- aaa: number | boolean
} else {
aaa // <- aaa: string
}
I'm Lucas Paganini, and in this blog, we release web development tutorials. Subscribe if you're interested in that.
Higher Order Functions
The theoretical description of a higher order function is a tongue twister: a function that receives a function and returns another function. So let me show it to you in practice, and you'll see that it's not as complex as it sounds.
Let's say we have a lot of custom type guards, and now we want inverted versions of them.
- We already have
isString
, now we wantisNotString
. - We already have
isNumber
, now we wantisNotNumber
. - You get the idea...
We did something very similar in the end of our third article, when we wrote a guard for truthy values that works by excluding falsy values.
type Truthy<T> = Exclude<T, Falsy>;
const isTruthy = <T extends unknown>(value: T): value is Truthy<T> =>
value == true;
// Test
let x: null | string | 0;
if (isTruthy(x)) {
x.trim(); // <- x: string
}
We can apply the same technique to create our inverted type guards. That's how they would look like:
const isNotString = <V extends unknown>(
value: V
): value is Exclude<V, string> => isString(value) === false;
const isNotNumber = <V extends unknown>(
value: V
): value is Exclude<V, number> => isNumber(value) === false;
But writing those inverted guards manually is tedious and repetitive. I bet you can see a pattern in them: all we need to create an inverted guard, is the custom type guard that will be inverted.
In other words: the only difference between isNotString
and isNotNumber
, is that while one uses the isString
guard, the other uses the isNumber
guard.
Higher Order Guards
Could we stop repeating ourselves and create a function that accepts a type guard as an argument and returns the inverted version of the given type guard?
const makeIsNot = (fn) => (v) => !fn(v);
const isNotString = makeIsNot(isString);
Hell yeah we can! Let's create it now!
I have a personal convention of prefixing functions with the word make
when they return new functions. So, it makes sense to me to call our function makeIsNot
, since it makes the is not version of a type guard.
makeIsNot
Implementation
The function implementation alone, is already a bit tricky, so I'll navigate it with you before we get into the TypeScript signature.
Let's use isNotString
as an example.
const makeIsNot = (fn) => (v) => !fn(v);
const isNotString = makeIsNot(isString);
Calling makeIsNot
with isString
, returns a function that receives one argument (called v
), and returns the inverted return of calling isString
with v
.
const makeIsNot = (fn) => (v) => !fn(v);
const isNotString = makeIsNot(isString);
const makeIsNot = (fn) => (v) => !fn(v);
const isNotString = (
(fn) => (v) =>
!fn(v)
)(isString);
The same works for isNotNumber
. Calling makeIsNot
with isNumber
, returns a function that receives one argument (called v
), and returns the inverted return of calling isNumber
with v
.
const makeIsNot = (fn) => (v) => !fn(v);
const isNotNumber = makeIsNot(isNumber);
const makeIsNot = (fn) => (v) => !fn(v);
const isNotNumber = (
(fn) => (v) =>
!fn(v)
)(isNumber);
makeIsNot
Signature
All is good and well with the implementation, now, to the type signature of makeIsNot
.
type MakeIsNot = <F extends (v: unknown) => v is any>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, F extends (v: unknown) => v is infer T ? T : never>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
Let's break it down and see what can be simplified.
Type Guard Function Type
First, there are two places where we're referring to a function that returns a type predicate (in other words, a custom type guard).
Let's create a type, called TypeGuardFunction
, to isolate that type definition and simplify our code a little.
type TypeGuardFunction<T = any> = (v: unknown) => v is T;
type MakeIsNot = <F extends TypeGuardFunction>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, F extends TypeGuardFunction<infer T> ? T : never>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
A little better, right?
Predicate Function Type
Also, even though the naming TypeGuardFunction
makes a lot of sense, since it is indeed a type guard function, this name is very specific to TypeScript. And it turns out that functions that receive an argument and return a boolean
already had a name before TypeScript even existed. Those functions are known as "Predicate Functions".
So, let's use the name PredicateFunction
instead.
type PredicateFunction<T = any> = (v: unknown) => v is T;
type MakeIsNot = <F extends PredicateFunction>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, F extends PredicateFunction<infer T> ? T : never>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
Unpack Predicate Function Type
Also, not that it repeats, but that part where we're inferring the type of the PredicateFunction
is kinda ugly to look at. Let's isolate that in a type.
👉 If you're at a lost with the ternary operator and the infer
keyword, I have two videos for you. Both are one minute long. One explains conditional types in TypeScript (the ternary operator), and the other explains the infer
operator. Their links are in the description.
I have another personal convention which is to use the prefix Unpack
when I'm creating a type that infers something. So I'll call it UnpackPredicateFunction
.
type PredicateFunction<T = any> = (v: unknown) => v is T;
type UnpackPredicateFunction<F extends PredicateFunction> =
F extends PredicateFunction<infer T> ? T : never;
type MakeIsNot = <F extends PredicateFunction>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, UnpackPredicateFunction<F>>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
Back to the Signature
Ok... it's not simple. But it is simpler. Let's try to analyze the signature now.
- First, we receive an argument, a
PredicateFunction
calledfn
; - Then, we return a new function;
- This new function receives an argument, called
v
. Which has the same type of the first parameter offn
; - And that, returns a type predicate saying that
v
is not of the type guarded by ourfn
function.
type PredicateFunction<T = any> = (v: unknown) => v is T;
type UnpackPredicateFunction<F extends PredicateFunction> =
F extends PredicateFunction<infer T> ? T : never;
type MakeIsNot = <F extends PredicateFunction>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, UnpackPredicateFunction<F>>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
Library
There are some limitations to our function. For example, right now, it only works with guards that receive a single argument. Also, it's not tested.
type PredicateFunction<T = any> = (v: unknown) => v is T;
type UnpackPredicateFunction<F extends PredicateFunction> =
F extends PredicateFunction<infer T> ? T : never;
type MakeIsNot = <F extends PredicateFunction>(
fn: F
) => <V extends Parameters<F>[0] = Parameters<F>[0]>(
v: V
) => v is Exclude<V, UnpackPredicateFunction<F>>;
const makeIsNot: MakeIsNot = (fn) => (v) => !fn(v);
const isNotString = makeIsNot(isString);
If you're interested in having makeIsNot
in your codebase (and also makeIsInstance
, makeIsIncluded
, and a lot more), instead of copying the code from this article, a better way is to just install my TypeScript utilities library.
import { makeIsNot } from '@lucaspaganini/ts';
const isNotString = makeIsNot(isString);
import { makeIsNot, makeIsInstance } from '@lucaspaganini/ts';
const isNotString = makeIsNot(isString);
const isArray = makeIsInstance(Array);
import { makeIsNot, makeIsInstance, makeIsIncluded } from '@lucaspaganini/ts';
const isNotString = makeIsNot(isString);
const isArray = makeIsInstance(Array);
const isFamousCat = makeIsIncluded(['Garfield', 'Tom']);
- It's open source
- Has tests
- Documentation
- Works on Node and Browsers
- It's MIT
- And you can easily install it with
npm install @lucaspaganini/ts
.
npm install @lucaspaganini/ts
We'll talk more about that library in the next article.
Conclusion
Today's content was pretty advanced. I remember how hard it was for me to learn functional programming and advanced TypeScript notations, so we really did our best with the examples and animations to hopefully, give you an easier learning experience than the one I had.
I would love to have a feedback from you. So, please send me a tweet and let us know if you could understand everything, and your questions, if you have any.
References are below. If you enjoyed the content, you know what to do.
And if your company is looking for remote web developers, consider contacting me and my team on lucaspaganini.com.
In the next article, we will use our newly found knowledge to create a workaround for a highly requested feature in TypeScript: asynchronous type guards. Subscribe if you don't want to miss it.
Until then, have a great day, and I’ll see you soon.
Top comments (0)