Narrowing Library

Narrowing Library

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.

LinkInstalling 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.

LinkCurrently 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:

  1. Core
  2. Assertions
  3. Predicates

LinkCore Module

Let's start with the core module.

The core module contains those 6 utilities:

  1. Mutable
  2. NonNullableProperties
  3. ObjectValues
  4. PickPropertyByType
  5. PickByType
  6. makeConstraint

Mutable is the opposite of the native Readonly type. It converts the readonly properties of a type into regular mutable properties.

TypeScript
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.

TypeScript
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.

TypeScript
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 Dates. We could use PickPropertyByType to get only the properties that are strings. Or the ones that are numbers. Or even the two that are Dates.

TypeScript
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.

TypeScript
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.

TypeScript
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".

TypeScript
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.

TypeScript
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"
TypeScript
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.

TypeScript
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.

TypeScript
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"

LinkAssertions Module

Cool, now let's get into the assertions module.

This module contains these 4 utilities:

  1. AssertionFunction
  2. UnpackAssertionFunction
  3. assertHasProperties
  4. fromPredicateFunction

An AssertionFunction is exactly what it seems. A function that makes a type assertion.

TypeScript
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.

TypeScript
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.

TypeScript
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.

LinkPredicates Module

The last module in our library is also the largest. The predicates module contains 11 utilities:

  1. PredicateFunction
  2. UnpackPredicateFunction
  3. UnguardedPredicateFunction
  4. AsyncPredicateFunction
  5. AsyncUnguardedPredicateFunction
  6. makeIsNot
  7. makeIsInstance
  8. makeIsIncluded
  9. makeHasProperties
  10. makeAsyncPredicateFunction
  11. 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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

TypeScript
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.

TypeScript
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
}

LinkSeries 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. πŸ™‚

LinkConclusion

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.

  1. TypeScript Narrowing pt. 1 - 8

LinkReferences

  1. TypeScript Utilities Library - @lucaspaganini/tsGithub Repository

Join our Newsletter and be the first to know when I launch a course, post a video or write an article.

This field is required
This field is required