DEV Community

Lucas Paganini
Lucas Paganini

Posted on • Originally published at lucaspaganini.com

Narrowing Library

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:

  1. Core
  2. Assertions
  3. Predicates

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

type Mutable<T> = { -readonly [P in keyof T]: T[P] }

Mutable<ReadonlyArray<number>>
//=> Array<number>

Mutable<{ readonly a: string }>
//=> { a: string }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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"
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

const assertIsString: AssertionFunction<string> = (v) => {
  if (typeof v !== 'string') throw Error('Not a string');
};

let aaa: number | string;
assertIsString(aaa);
aaa; // <- aaa: string
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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:

  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.

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. TypeScript Narrowing pt. 1 - 8

References

  1. TypeScript Utilities Library - @lucaspaganini/tsGithub Repository

Top comments (0)