TypeScript Narrowing #8
See this and many other articles at lucaspaganini.com
Hello, welcome to the last article of our TypeScript Narrowing series!
Today, I'll show you an open sourced library that I wrote using the same techniques discussed in our previous articles. This library is by no means rigid to anyone's workflow, on the contrary, I made it with the intention of it being valuable to anyone working on a TypeScript codebase.
I'm Lucas Paganini, and on this website, we release web development tutorials. Subscribe if you're interested in that.
Installing and Target Audience
Before we talk about what's inside, you should know how to install it and who should install it.
I made this library available on NPM, to add it to your codebase, simply run npm install @lucaspaganini/ts
.
Now, regarding who should install it, I see this library as "Lodash for TypeScript". It provides you with flexible and type safe utilities that make your codebase cleaner. Also, everything is isolated, so you can install it and only import the things that you actually want to use.
That said, I truly believe that this library is useful for anyone who's working on a TypeScript codebase. Frontend, backend, whatever... If you're using TypeScript, you'll benefit from having those utilities.
Currently Available Modules
Without further ado, let's explore what's currently available in the library.
👉 I say "currently available" because it's a living thing. Over time, we will add more to it.
So far, our library has 3 modules:
- Core
- Assertions
- Predicates
Core Module
Let's start with the core module.
The core module contains those 6 utilities:
Mutable
NonNullableProperties
ObjectValues
PickPropertyByType
PickByType
makeConstraint
Mutable
is the opposite of the native Readonly
type. It converts the readonly properties of a type into regular mutable properties.
type Mutable<T> = { -readonly [P in keyof T]: T[P] }
Mutable<ReadonlyArray<number>>
//=> Array<number>
Mutable<{ readonly a: string }>
//=> { a: string }
NonNullableProperties
is similar, it converts all the properties of a type into non-nullable properties.
type NonNullableProperties<T> =
{ [P in keyof Required<T>]: NonNullable<T[P]> }
NonNullableProperties<{ a: string | null }>
//=> { a: string }
NonNullableProperties<{ b?: number }>
//=> { b: number }
NonNullableProperties<{ c: Date | undefined }>
//=> { c: Date }
Then we have ObjectValues
, which returns a union type of the types of all the properties in an object. So if your object has three properties, being them a string
, a number
and a Date
. ObjectValues
will give you the string | number | Date
type.
👉 I can't tell you how useful that is.
type ObjectValues<O> = O[keyof O]
ObjectValues<{ a: string ; b: number; c: Date }>
//=> string | number | Date
PickPropertyByType
returns the keys of the properties that match the expected type.
Similar to our last example, if we have an object with four properties, one being a string
, another being a number
and the last two being Date
s. We could use PickPropertyByType
to get only the properties that are string
s. Or the ones that are number
s. Or even the two that are Date
s.
type PickPropertyByType<O, T> =
ObjectValues<{ [P in keyof O]: O[P] extends T ? P : never }>
type Test = { a: string; b: number; c: Date; d: Date }
PickPropertyByType<Test, string>
//=> "a"
PickPropertyByType<Test, number>
//=> "b"
PickPropertyByType<Test, Date>
//=> "c" | "d"
Similarly, PickByType
returns an object that only contains the properties that match the expected type.
type PickByType<O, T> = Pick<O, PickPropertyByType<O, T>>
type Test = { a: string; b: number; c: Date; d: Date }
PickByType<Test, string>
//=> { a: string }
PickByType<Test, number>
//=> { b: number }
PickByType<Test, Date>
//=> { c: Date; d: Date }
And last but not least, makeConstraint
allows us to set a type constraint and still keep the literal types.
const makeConstraint =
<T>() =>
<V extends T>(v: V): typeof v =>
v;
For example, let's say we have a type called Icon
that contains a name and an id. Both properties should be strings.
Then we declare a ReadonlyArray<Icon>
with two icons, one with the id "error"
and the other with the id "success"
.
Now, if you try to extract the IconID
based on the type of icons
, it will be string
. But that's too broad. IconID
should be "error" | "success"
.
type Icon = { id: string; name: string };
const icons: ReadonlyArray<Icon> = [
{ id: 'error', name: 'Error, sorry' },
{ id: 'success', name: 'Success, yaaay' }
] as const;
type IconID = typeof icons[number]['id'];
//=> IconID = string
If we remove the casting of icons
to ReadonlyArray<Icon>
, we get what we want, but then we lose the type safety of icons
.
type Icon = { id: string; name: string };
const icons = [
{ id: 'error', name: 'Error, sorry' },
{ id: 'success', name: 'Success, yaaay' }
] as const;
type IconID = typeof icons[number]['id'];
//=> IconID = "error" | "success"
type Icon = { id: string; name: string };
const icons = [
{ id: 'error', foo: 'Error, sorry' },
{ id: 'success', bar: 'Success, yaaay' }
] as const;
type IconID = typeof icons[number]['id'];
//=> IconID = "error" | "success"
That's where makeConstraint
comes into play.
const makeConstraint =
<T>() =>
<V extends T>(v: V): typeof v =>
v
type Icon = { id: string; name: string }
const iconsConstraint = makeConstraint<ReadonlyArray<Icon>>()
const icons = iconsConstraint([
{ id: 'error', foo: 'Error, sorry' }, //=> Error
{ id: 'success', bar: 'Success, yaaay' }, => Error
] as const)
type IconID = typeof icons[number]['id']
//=> IconID = "error" | "success"
With it, we can make sure that icons
is a ReadonlyArray<Icon>
but still get its literal readonly types.
const makeConstraint =
<T>() =>
<V extends T>(v: V): typeof v =>
v;
type Icon = { id: string; name: string };
const iconsConstraint = makeConstraint<ReadonlyArray<Icon>>();
const icons = iconsConstraint([
{ id: 'error', name: 'Error, sorry' },
{ id: 'success', name: 'Success, yaaay' }
] as const);
type IconID = typeof icons[number]['id'];
//=> IconID = "error" | "success"
Assertions Module
Cool, now let's get into the assertions module.
This module contains these 4 utilities:
AssertionFunction
UnpackAssertionFunction
assertHasProperties
fromPredicateFunction
An AssertionFunction
is exactly what it seems. A function that makes a type assertion.
const assertIsString: AssertionFunction<string> = (v) => {
if (typeof v !== 'string') throw Error('Not a string');
};
let aaa: number | string;
assertIsString(aaa);
aaa; // <- aaa: string
And UnpackAssertionFunction
returns the type asserted by an AssertionFunction
.
const assertIsString: AssertionFunction<string> = v => {
if (typeof v !== 'string') throw Error('Not a string')
}
UnpackAssertionFunction<typeof assertIsString>
//=> string
assertHasProperties
asserts that the given value has the given properties, and throws if it doesn't.
👉 To keep things safe, the asserted properties are typed as unknown
, check this one-minute video to understand the differences between any
and unknown
.
let foo: unknown = someUnknownObject;
// Usage
foo.a; // <- Compilation error
assertHasProperties(['a'], foo);
foo.a; // <- foo: { a: unknown }
And the last utility in the assertions module is fromPredicateFunction
. It takes a PredicateFunction
, which we'll talk about in a second, and returns an AssertionFunction
.
Predicates Module
The last module in our library is also the largest. The predicates module contains 11 utilities:
PredicateFunction
UnpackPredicateFunction
UnguardedPredicateFunction
AsyncPredicateFunction
AsyncUnguardedPredicateFunction
makeIsNot
makeIsInstance
makeIsIncluded
makeHasProperties
makeAsyncPredicateFunction
fromAssertionFunction
The first one, PredicateFunction
, is a type guard. It takes a value and returns a type predicate.
You may be tempted to call this a "type guard", but as I've mentioned in the sixth article of this series (the one about higher order guards), the "type guard" naming is very specific to TypeScript, and these types of functions have been called "predicate functions" way before TypeScript even existed.
type PredicateFunction<T = any> = (v: unknown) => v is T;
const isString: PredicateFunction<string> = (v): v is string =>
typeof v === 'string';
let aaa: number | string;
if (isString(aaa)) {
aaa; // <- aaa: string
}
Similarly to UnpackAssertionFunction
, we can use UnpackPredicateFunction
to extract the type guarded by a PredicateFunction
.
type PredicateFunction<T = any> = (v: unknown) => v is T
const isString: PredicateFunction<string> =
(v): v is string => typeof v === 'string'
type UnpackPredicateFunction<F extends PredicateFunction> =
F extends PredicateFunction<infer T> ? T : never
UnpackPredicateFunction<typeof isString>
//=> string
Sometimes we have predicate functions that don't return a type predicate, they just return a regular boolean
. For those cases, we have the UnguardedPredicateFunction
.
For example, isEqual
is an UnguardedPredicateFunction
.
type UnguardedPredicateFunction<Params extends Array<any> = Array<any>> = (
...args: Params
) => boolean;
const isEqual = (a: number, b: number): boolean => a === b;
Then we have the AsyncPredicateFunction
, AsyncUnguardedPredicateFunction
and makeAsyncPredicateFunction
. I won't go deeper into them because the seventh article of our TypeScript Narrowing series was all about them, so I'm not going to waste your time repeating information haha.
type AsyncPredicateFunction<T = any> = (
value: unknown
) => Promise<PredicateFunction<T>>;
type AsyncUnguardedPredicateFunction<Params extends Array<any> = Array<any>> = (
...args: Params
) => Promise<boolean>;
type MakeAsyncPredicateFunction = {
<F extends AsyncUnguardedPredicateFunction>(fn: F): (
...args: Parameters<F>
) => Promise<UnguardedPredicateFunction<Parameters<F>>>;
<T>(fn: AsyncUnguardedPredicateFunction): AsyncPredicateFunction<T>;
};
makeIsNot
was also mentioned previously, in the sixth article. It takes a PredicateFunction
and returns the inverted version of it.
const isNumber: PredicateFunction<number> = (v): v is number =>
typeof v === 'number';
const isNotNumber = makeIsNot(isNumber);
let aaa: number | string | Date;
if (isNotNumber(aaa)) {
aaa; // -> aaa: string | Date
} else {
aaa; // -> aaa: number
}
makeIsInstance
is new though. It takes a class constructor and returns a PredicateFunction
that checks if a value is an instanceof
the given class constructor.
const makeIsInstance =
<C extends new (...args: any) => any>(
classConstructor: C
): PredicateFunction<InstanceType<C>> =>
(v): v is InstanceType<C> =>
v instanceof classConstructor;
// The following expressions are equivalent:
const isDate = makeIsInstance(Date);
const isDate = (v: any): v is Date => v instanceof Date;
makeIsIncluded
takes an Iterable
and returns a PredicateFunction
that checks if a value is included in the given iterable.
const makeIsIncluded = <T>(iterable: Iterable<T>): PredicateFunction<T> => {
const set = new Set(iterable);
return (v: any): v is T => set.has(v);
};
// The following expressions are equivalent:
const abc = ['a', 'b', 'c'];
const isInABC = makeIsIncluded(abc);
const isInABC = (v: any): v is 'a' | 'b' | 'c' => abc.includes(v);
And finally, just like in the assertions module, we have makeHasProperties
and fromAssertionFunction
.
makeHasProperties
takes an array of properties and returns a PredicateFunction
that checks if a value has those properties
let foo: unknown = someUnknownObject;
// Usage
foo.a; // <- Compilation error
const hasPropA = makeHasProperties(['a']);
if (hasPropA(foo)) {
foo.a; // <- foo: { a: unknown }
}
And fromAssertionFunction
takes an AssertionFunction
and returns a PredicateFunction
.
type Assert1 = (v: unknown) => asserts v is 1;
const assert1: Assert1 = (v: unknown): asserts v is 1 => {
if (v !== 1) throw Error('');
};
const is1 = fromAssertionFunction(assert1);
declare const aaa: 1 | 2 | 3;
if (is1(aaa)) {
// <- aaa: 1
} else {
// <- aaa: 2 | 3
}
Series Outro
It's the end, but don't close the article yet, I have some things to say.
This is the last article of our TypeScript narrowing series. The first series I did here and on YouTube.
I'm super happy with the quality that we were able to put out, but I also have big dreams. I want to make things crazy better! And that's why me and my team are building a platform for interactive learning experiences.
Imagine consuming my content and in the middle of it there's a mini-game for you, or a 3D animation, or a quick quiz to consolidate your knowledge. You get the idea.
And all that, available in many languages. We currently offer our content in English and Portuguese. But I also want to offer it in Spanish, German, French, and so many others!
For now, we're releasing all that content for free, but I think it's obvious to say that we'll eventually have paid courses, and I want them to be f** awesome! Like, deliver *unbelievable* value!
So sure, if you haven't yet, I highly encourage you to subscribe to the newsletter. Your support is highly appreciated.
Thank you so much for sticking with me, I hope you enjoyed it, and I hope this is only the beginning of a long journey on YouTube and content creation in general. 🙂
Conclusion
As always, references are below.
And if your company is looking for remote web developers, please consider contacting me and my team on lucaspaganini.com.
Until then, have a great day, and I’ll see you in the next one.
Related Content
- TypeScript Narrowing pt. 1 - 8
Top comments (0)