Chapter 16

Utility Folder Development

Learn TypeScript generic functions, type parameters, predicates, and function overloads with practical exercises. Complex type manipulations and debugging.

16

It's commonly thought that there are two levels of TypeScript complexity.

On one end, you have library development. Here, you take advantage of many of TypeScript's most arcane and powerful features. You'll need conditional types, mapped types, generics, and much more to create a library that's flexible enough to be used in a variety of scenarios.

On the other end, you have application development. Here, you're mostly concerned with making sure your code is type-safe. You want to make sure your types reflect what's happening in your application. Any complex types are housed in libraries you use. You'll need to know your way around TypeScript, but you won't need to use its advanced features much.

This is the rule of thumb most of the TypeScript community use. "It's too complex for application code". "You'll only need it in libraries". But there's a third level that's often overlooked: the /utils folder.

If your application gets big enough, you'll start capturing common patterns in a set of reusable functions. These functions, like groupBy, debounce, and retry, might be used hundreds of times across a large application. They're like mini-libraries within the scope of your application.

Understanding how to build these types of functions can save your team a lot of time. Capturing common patterns means your code becomes easier to maintain, and faster to build.

In this chapter we'll cover how to build these functions. We'll start with generic functions, then head to type predicates, assertion functions, and function overloads.

Generic Functions

We've seen that in TypeScript, functions can receive not just values as arguments, but types too. Here, we're passing a value and a type to new Set():

const set = new Set<number>([1, 2, 3]);
//                 ^^^^^^^^ ^^^^^^^^^
//                 type     value

We pass the type in the angle brackets, and the value in the parentheses. This is because new Set() is a generic function. A function that can't receive types is a regular function, like JSON.parse:

const obj = JSON.parse<{ hello: string }>('{"hello": "world"}');
Expected 0 type arguments, but got 1.2558
Expected 0 type arguments, but got 1.

Here, TypeScript is telling us that JSON.parse doesn't accept type arguments, because it's not generic.

What Makes A Function Generic?

A function is generic if it declares a type parameter. Here's a generic function that takes a type parameter T:

function identity<T>(arg: T): T {
  //                 ^^^ Type parameter
  return arg;
}

We can use the function keyword, or use arrow function syntax:

const identity = <T>(arg: T): T => arg;

We can even declare a generic function as a type:

type Identity = <T>(arg: T) => T;

const identity: Identity = (arg) => arg;

Now, we can pass a type argument to identity:

identity<number>(42);

Generic Function Type Alias vs Generic Type

It's very important not to confuse the syntax for a generic type with the syntax for a type alias for a generic function. They look very similar to the untrained eye. Here's the difference:

// Type alias for a generic function
type Identity = <T>(arg: T) => T;
//              ^^^
//              Type parameter belongs to the function

// Generic type
type Identity<T> = (arg: T) => T;
//           ^^^
//           Type parameter belongs to the type

It's all about the position of the type parameter. If it's attached to the type's name, it's a generic type. If it's attached to the function's parentheses, it's a type alias for a generic function.

What Happens When We Don't Pass In A Type Argument?

When we looked at generic types, we saw that TypeScript requires you to pass in all type arguments when you use a generic type:

type StringArray = Array<string>;

type AnyArray = Array;
Generic type 'Array<T>' requires 1 type argument(s).2314
Generic type 'Array<T>' requires 1 type argument(s).

This is not true of generic functions. If you don't pass a type argument to a generic function, TypeScript won't complain:

function identity<T>(arg: T): T {
  return arg;
}

const result = identity(42); // No error!

Why is this? Well, it's the feature of generic functions that make them my favourite TypeScript tool. If you don't pass a type argument, TypeScript will attempt to infer it from the function's runtime arguments.

Our identity function above simply takes in an argument and returns it. We've referenced the type parameter in the runtime parameter: arg: T. This means that if we don't pass in a type argument, T will be inferred from the type of arg.

So, result will be typed as 42:

const result = identity(42);
const result: 42

This means that every time the function is called, it can potentially return a different type:

const result1 = identity("hello");
const result1: "hello"
const result2 = identity({ hello: "world" });
const result2: { hello: string; }
const result3 = identity([1, 2, 3]);
const result3: number[]

This ability means that your functions can understand what types they're working with, and alter their suggestions and errors accordingly. It's TypeScript at its most powerful and flexible.

Specified Types Beat Inferred Types

Let's go back to specifying type arguments instead of inferring them. What happens if your type argument you pass conflicts with the runtime argument?

Let's try it with our identity function:

const result = identity<string>(42);
Argument of type 'number' is not assignable to parameter of type 'string'.2345
Argument of type 'number' is not assignable to parameter of type 'string'.

Here, TypeScript is telling us that 42 is not a string. This is because we've explicitly told TypeScript that T should be a string, which conflicts with the runtime argument.

Passing type arguments is an instruction to TypeScript override inference. If you pass in a type argument, TypeScript will use it as the source of truth. If you don't, TypeScript will use the type of the runtime argument as the source of truth.

There Is No Such Thing As 'A Generic'

A quick note on terminology here. TypeScript 'generics' has a reputation for being difficult to understand. I think a large part of that is based on how people use the word 'generic'.

A lot of people think of a 'generic' as a part of TypeScript. They think of it like a noun. If you ask someone "where's the 'generic' in this piece of code?":

const identity = <T>(arg: T) => arg;

They will probably point to the <T>. Others might describe the code below as "passing a 'generic' to Set":

const set = new Set<number>([1, 2, 3]);

This terminology gets very confusing. Instead, I prefer to split them into different terms:

  • Type Parameter: The <T> in identity<T>.
  • Type Argument: The number passed to Set<number>.
  • Generic Class/Function/Type: A class, function or type that declares a type parameter.

When you break generics down into these terms, it becomes much easier to understand.

The Problem Generic Functions Solve

Let's put what we've learned into practice.

Consider this function called getFirstElement that takes an array and returns the first element:

const getFirstElement = (arr: any[]) => {
  return arr[0];
};

This function is dangerous. Because it takes an array of any, it means that the thing we get back from getFirstElement is also any:

const first = getFirstElement([1, 2, 3]);
const first: any

As we've seen, any can cause havoc in your code. Anyone who uses this function will be unwittingly opting out of TypeScript's type safety. So, how can we fix this?

We need TypeScript to understand the type of the array we're passing in, and use it to type what's returned. We need to make getFirstElement generic:

To do this, we'll add a type parameter TMember before the function's parameter list, then use TMember[] as the type for the array:

const getFirstElement = <TMember>(arr: TMember[]) => {
  return arr[0];
};

Just like generic functions, it's common to prefix your type parameters with T to differentiate them from normal types.

Now when we call getFirstElement, TypeScript will infer the type of `` based on the argument we pass in:

const firstNumber = getFirstElement([1, 2, 3]);
const firstNumber: number
const firstString = getFirstElement(["a", "b", "c"]);
const firstString: string

Now, we've made getFirstElement type-safe. The type of the array we pass in is the type of the thing we get back.

Debugging The Inferred Type Of Generic Functions

When you're working with generic functions, it can be hard to know what type TypeScript has inferred. However, with a carefully-placed hover, you can find out.

When we call the getFirstElement function, we can hover over the function name to see what TypeScript has inferred:

const first = getFirstElement([1, 2, 3]);
const getFirstElement: <number>(arr: number[]) => number

We can see that within the angle brackets, TypeScript has inferred that TMember is number, because we passed in an array of numbers.

This can be useful when you have more complex functions with multiple type parameters to debug. I often find myself creating temporary function calls in the same file to see what TypeScript has inferred.

Type Parameter Defaults

Just like generic types, you can set default values for type parameters in generic functions. This can be useful when runtime arguments to the function are optional:

const createSet = <T = string>(arr?: T[]) => {
  return new Set(arr);
};

Here, we set the default type of T to string. This means that if we don't pass in a type argument, TypeScript will assume T is string:

const defaultSet = createSet();
const defaultSet: Set<string>

The default doesn't impose a constraint on the type of T. This means we can still pass in any type we want:

const numberSet = createSet<number>([1, 2, 3]);
const numberSet: Set<number>

If we don't specify a default, and TypeScript can't infer the type from the runtime arguments, it will default to unknown:

const createSet = <T>(arr?: T[]) => {
  return new Set(arr);
};

const unknownSet = createSet();
const unknownSet: Set<unknown>

Here, we've removed the default type of T, and TypeScript has defaulted to unknown.

Constraining Type Parameters

You can also add constraints to type parameters in generic functions. This can be useful when you want to ensure that a type has certain properties.

Let's imagine a removeId function that takes an object and removes the id property:

const removeId = <TObj>(obj: TObj) => {
  const { id, ...rest } = obj;
Property 'id' does not exist on type 'unknown'.2339
Property 'id' does not exist on type 'unknown'. return rest; };

Our TObj type parameter, when used without a constraint, is treated as unknown. This means that TypeScript doesn't know if id exists on obj.

To fix this, we can add a constraint to TObj that ensures it has an id property:

const removeId = <TObj extends { id: unknown }>(obj: TObj) => {
  const { id, ...rest } = obj;
  return rest;
};

Now, when we use removeId, TypeScript will error if we don't pass in an object with an id property:

const result = removeId({ name: "Alice" });
Object literal may only specify known properties, and 'name' does not exist in type '{ id: unknown; }'.2353
Object literal may only specify known properties, and 'name' does not exist in type '{ id: unknown; }'.

But if we pass in an object with an id property, TypeScript will know that id has been removed:

const result = removeId({ id: 1, name: "Alice" });
const result: Omit<{ id: number; name: string; }, "id">

Note how clever TypeScript is being here. Even though we didn't specify a return type for removeId, TypeScript has inferred that result is an object with all the properties of the input object, except id.

Type Predicates

We were introduced to type predicates way back in chapter 5, when we looked at narrowing. They're used to capture reusable logic that narrows the type of a variable.

For example, say we want to ensure that a variable is an Album before we try accessing its properties or passing it to a function that requires an Album.

We can write an isAlbum function that takes in an input, and checks for all the required properties.

function isAlbum(input: unknown) {
  return (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input
  );
}

If we hover over isAlbum, we can see a rather ugly type signature:

// hovering over isAlbum shows:
function isAlbum(
  input: unknown,
): input is object &
  Record<"id", unknown> &
  Record<"title", unknown> &
  Record<"artist", unknown> &
  Record<"year", unknown>;

This is technically correct: a big intersection between an object and a bunch of Records. But it's not very helpful.

When we try to use isAlbum to narrow the type of a value, TypeScript won't infer it correctly:

// @errors: 18046
function isAlbum(input: unknown) {
  return (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input
  );
}

// ---cut---
const run = (maybeAlbum: unknown) => {
  if (isAlbum(maybeAlbum)) {
    maybeAlbum.name.toUpperCase();
  }
};

To fix this, we'd need to add even more checks to isAlbum to ensure we're checking the types of all the properties:

function isAlbum(input: unknown) {
  return (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input &&
    typeof input.id === "number" &&
    typeof input.title === "string" &&
    typeof input.artist === "string" &&
    typeof input.year === "number"
  );
}

This can feel far too verbose. We can make it more readable by adding our own type predicate.

function isAlbum(input: unknown): input is Album {
  return (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input
  );
}

Now, when we use isAlbum, TypeScript will know that the type of the value has been narrowed to Album:

const run = (maybeAlbum: unknown) => {
  if (isAlbum(maybeAlbum)) {
    maybeAlbum.name.toUpperCase(); // No error!
  }
};

For complex type guards, this can be much more readable.

Type Predicates Can be Unsafe

Authoring your own type predicates can be a little dangerous. If the type predicate doesn't accurately reflect the type being checked, TypeScript won't catch that discrepancy:

function isAlbum(input): input is Album {
  return typeof input === "object";
}

In this case, any object passed to isAlbum will be considered an Album, even if it doesn't have the required properties. This is a common pitfall when working with type predicates - it's important to consider them about as unsafe as as and !.

Assertion Functions

Assertion functions look similar to type predicates, but they're used slightly differently. Instead of returning a boolean to indicate whether a value is of a certain type, assertion functions throw an error if the value isn't of the expected type.

Here's how we could rework the isAlbum type predicate to be an assertIsItem assertion function:

function assertIsAlbum(input: unknown): asserts input is Album {
  if (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input
  ) {
    throw new Error("Not an Album!");
  }
}

The assertIsAlbum function takes in a input of type unknown and asserts that it is an Album using the asserts input is Album syntax.

This means that the narrowing is more aggressive. Instead of checking within an if statement, the function call itself is enough to assert that the input is an Album.

function getAlbumTitle(item: unknown) {
  console.log(item);
(parameter) item: unknown
assertIsAlbum(item); console.log(item.title);
(parameter) item: Album
}

Assertion functions can be useful when you want to ensure that a value is of a certain type before proceeding with further operations.

Assertion Functions Can Lie

Just like type predicates, assertion functions can be misused. If the assertion function doesn't accurately reflect the type being checked, it can lead to runtime errors.

For example, if the assertIsAlbum function doesn't check for all the required properties of an Album, it can lead to unexpected behavior:

function assertIsAlbum(input: unknown): asserts input is Album {
  if (typeof input === "object") {
    throw new Error("Not an Album!");
  }
}

let item = null;

assertIsAlbum(item);

item.title;
// ^?

In this case, the assertIsAlbum function doesn't check for the required properties of an Album - it just checks if typeof input is "object". This means we've left ourselves open to a stray null. The famous JavaScript quirk where typeof null === 'object' will cause a runtime error when we try to access the title property.

Function Overloads

Function overloads provide a way to define multiple function signatures for a single function implementation. In other words, you can define different ways to call a function, each with its own set of parameters and return types. It's an interesting technique for creating a flexible API that can handle different use cases while maintaining type safety.

To demonstrate how function overloads work, we'll create a searchMusic function that allows for different ways to perform a search based on the provided arguments.

Defining Overloads

To define function overloads, the same function definition is written multiple times with different parameter and return types. Each definition is called an overload signature, and is separated by semicolons. You'll also need to use the function keyword each time.

For the searchMusic example, we want to allow users to search by providing an artist, genre and year. But for legacy reasons, we want them to be able to pass them as a single object or as separate arguments.

Here's how we could define these function overload signatures. The first signature takes in three separate arguments, while the second signature takes in a single object with the properties:

function searchMusic(artist: string, genre: string, year: number): void;
function searchMusic(criteria: {
Function implementation is missing or not immediately following the declaration.2391
Function implementation is missing or not immediately following the declaration. artist: string; genre: string; year: number; }): void;

But we're getting an error. We've declared some ways this function should be declared, but we haven't provided the implementation yet.

The Implementation Signature

The implementation signature is the actual function declaration that contains the actual logic for the function. It is written below the overload signatures, and must be compatible with all the defined overloads.

In this case, the implementation signature will take in a parameter called queryOrCriteria that can be either a string or an object with the specified properties. Inside the function, we'll check the type of queryOrCriteria and perform the appropriate search logic based on the provided arguments:

function searchMusic(artist: string, genre: string, year: number): void;
function searchMusic(criteria: {
  artist: string;
  genre: string;
  year: number;
}): void;
function searchMusic(
  artistOrCriteria: string | { artist: string; genre: string; year: number },
  genre?: string,
  year?: number,
): void {
  if (typeof artistOrCriteria === "string") {
    // Search with separate arguments
    search(artistOrCriteria, genre, year);
  } else {
    // Search with object
    search(
      artistOrCriteria.artist,
      artistOrCriteria.genre,
      artistOrCriteria.year,
    );
  }
}

Now we can call the searchMusic function with the different arguments defined in the overloads:

searchMusic("King Gizzard and the Lizard Wizard", "Psychedelic Rock", 2021);
searchMusic({
  artist: "Tame Impala",
  genre: "Psychedelic Rock",
  year: 2015,
});

However, TypeScript will warn us if we attempt to pass in an argument that doesn't match any of the defined overloads:

searchMusic(
No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.2575
No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments. { artist: "Tame Impala", genre: "Psychedelic Rock", year: 2015, }, "Psychedelic Rock", );

This error shows us that we're trying to call searchMusic with two arguments, but the overloads only expect one or three arguments.

Function Overloads vs Unions

Function overloads can be useful when you have multiple call signatures spread over different sets of arguments. In the example above, we can either call the function with one argument, or three.

When you have the same number of arguments but different types, you should use a union type instead of function overloads. For example, if you want to allow the user to search by either artist name or criteria object, you could use a union type:

function searchMusic(
  query: string | { artist: string; genre: string; year: number },
): void {
  if (typeof query === "string") {
    // Search by artist
    searchByArtist(query);
  } else {
    // Search by all
    search(query.artist, query.genre, query.year);
  }
}

This uses far fewer lines of code than defining two overloads and an implementation.

Exercises

Exercise 1: Make a Function Generic

Here we have a function createStringMap. The purpose of this function is to generate a Map with keys as strings and values of the type passed in as arguments:

const createStringMap = () => {
  return new Map();
};

As it currently stands, we get back a Map<any, any>. However, the goal is to make this function generic so that we can pass in a type argument to define the type of the values in the Map.

For example, if we pass in number as the type argument, the function should return a Map with values of type number:

const numberMap = createStringMap<number>();
Expected 0 type arguments, but got 1.2558
Expected 0 type arguments, but got 1. numberMap.set("foo", 123);

Likewise, if we pass in an object type, the function should return a Map with values of that type:

const objMap = createStringMap<{ a: number }>();
Expected 0 type arguments, but got 1.2558
Expected 0 type arguments, but got 1. objMap.set("foo", { a: 123 }); objMap.set( "bar", // @ts-expect-error
Unused '@ts-expect-error' directive.2578
Unused '@ts-expect-error' directive. { b: 123 }, );

The function should also default to unknown if no type is provided:

const unknownMap = createStringMap();

type test = Expect<Equal<typeof unknownMap, Map<string, unknown>>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'.

Your task is to transform createStringMap into a generic function capable of accepting a type argument to describe the values of Map. Make sure it functions as expected for the provided test cases.

Exercise 1: Make a Function Generic

Exercise 2: Default Type Arguments

After making the createStringMap function generic in Exercise 1, calling it without a type argument defaults to values being typed as unknown:

const stringMap = createStringMap();

// hovering over stringMap shows:
const stringMap: Map<string, unknown>;

Your goal is to add a default type argument to the createStringMap function so that it defaults to string if no type argument is provided. Note that you will still be able to override the default type by providing a type argument when calling the function.

Exercise 2: Default Type Arguments

Exercise 3: Inference in Generic Functions

Consider this uniqueArray function:

const uniqueArray = (arr: any[]) => {
  return Array.from(new Set(arr));
};

The function accepts an array as an argument, then converts it to a Set, then returns it as a new array. This is a common pattern for when you want to have unique values inside your array.

While this function operates effectively at runtime, it lacks type safety. It transforms any array passed in into any[].

it("returns an array of unique values", () => {
  const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);

  type test = Expect<Equal<typeof result, number[]>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. expect(result).toEqual([1, 2, 3, 4, 5]); }); it("should work on strings", () => { const result = uniqueArray(["a", "b", "b", "c", "c", "c"]); type test = Expect<Equal<typeof result, string[]>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. expect(result).toEqual(["a", "b", "c"]); });

Your task is to boost the type safety of the uniqueArray function by making it generic.

Note that in the tests, we do not explicitly provide type arguments when invoking the function. TypeScript should be able to infer the type from the argument.

Adjust the function and insert the necessary type annotations to ensure that the result type in both tests is inferred as number[] and string[], respectively.

Exercise 3: Inference in Generic Functions

Exercise 4: Type Parameter Constraints

Consider this function addCodeToError, which accepts a type parameter TError and returns an object with a code property:

const UNKNOWN_CODE = 8000;

const addCodeToError = <TError>(error: TError) => {
  return {
    ...error,
    code: error.code ?? UNKNOWN_CODE,
Property 'code' does not exist on type 'TError'.2339
Property 'code' does not exist on type 'TError'. }; };

If the incoming error doesn't include a code, the function assigns a default UNKNOWN_CODE. Currently there is an error under the code property.

Currently, there are no constraints on TError, which can be of any type. This leads to errors in our tests:

it("Should accept a standard error", () => {
  const errorWithCode = addCodeToError(new Error("Oh dear!"));

  type test1 = Expect<Equal<typeof errorWithCode, Error & { code: number }>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. console.log(errorWithCode.message); type test2 = Expect<Equal<typeof errorWithCode.message, string>>; }); it("Should accept a custom error", () => { const customErrorWithCode = addCodeToError({ message: "Oh no!", code: 123, filepath: "/", }); type test3 = Expect< Equal<
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. typeof customErrorWithCode, { message: string; code: number; filepath: string; } & { code: number; } > >; type test4 = Expect<Equal<typeof customErrorWithCode.message, string>>; });

Your task is to update the addCodeToError type signature to enforce the required constraints so that TError is required to have a message property and can optionally have a code property.

Exercise 4: Type Parameter Constraints

Exercise 5: Combining Generic Types and Functions

Here we have safeFunction, which accepts a function func typed as PromiseFunc that returns a function itself. However, if func encounters an error, it is caught and returned instead:

type PromiseFunc = () => Promise<any>;

const safeFunction = (func: PromiseFunc) => async () => {
  try {
    const result = await func();
    return result;
  } catch (e) {
    if (e instanceof Error) {
      return e;
    }
    throw e;
  }
};

In short, the thing that we get back from safeFunction should either be the thing that's returned from func or an Error.

However, there are some issues with the current type definitions.

The PromiseFunc type is currently set to always return Promise<any>. This means that the function returned by safeFunction is supposed to return either the result of func or an Error, but at the moment, it's just returning Promise<any>.

There are several tests that are failing due to these issues:

it("should return an error if the function throws", async () => {
  const func = safeFunction(async () => {
    if (Math.random() > 0.5) {
      throw new Error("Something went wrong");
    }
    return 123;
  });

  type test1 = Expect<Equal<typeof func, () => Promise<Error | number>>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. const result = await func(); type test2 = Expect<Equal<typeof result, Error | number>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'.}); it("should return the result if the function succeeds", async () => { const func = safeFunction(() => { return Promise.resolve(`Hello!`); }); type test1 = Expect<Equal<typeof func, () => Promise<string | Error>>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. const result = await func(); type test2 = Expect<Equal<typeof result, string | Error>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. expect(result).toEqual("Hello!"); });

Your task is to update safeFunction to have a generic type parameter, and update PromiseFunc to not return Promise<Any>. This will require you to combine generic types and functions to ensure that the tests pass successfully.

Exercise 5: Combining Generic Types and Functions

Exercise 6: Multiple Type Arguments in a Generic Function

After making the safeFunction generic in Exercise 5, it's been updated to allow for passing arguments:

const safeFunction =
  <TResult>(func: PromiseFunc<TResult>) =>
  async (...args: any[]) => {
    //   ^^^^^^^^^^^^^^ Now can receive args!
    try {
      const result = await func(...args);
      return result;
    } catch (e) {
      if (e instanceof Error) {
        return e;
      }
      throw e;
    }
  };

Now that the function being passed into safeFunction can receive arguments, the function we get back should also contain those arguments and require you to pass them in.

However, as seen in the tests, this isn't working:

it("should return the result if the function succeeds", async () => {
  const func = safeFunction((name: string) => {
    return Promise.resolve(`hello ${name}`);
  });

  type test1 = Expect<
    Equal<typeof func, (name: string) => Promise<Error | string>>
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. >; });

For example, in the above test the name isn't being inferred as a parameter of the function returned by safeFunction. Instead, it's actually saying that we can pass in as many arguments as we want to into the function, which isn't correct.

// hovering over func shows:
const func: (...args: any[]) => Promise<string | Error>;

Your task is to add a second type parameter to PromiseFunc and safeFunction to infer the argument types accurately.

As seen in the tests, there are cases where no parameters are necessary, and others where a single parameter is needed:

it("should return an error if the function throws", async () => {
  const func = safeFunction(async () => {
    if (Math.random() > 0.5) {
      throw new Error("Something went wrong");
    }
    return 123;
  });

  type test1 = Expect<Equal<typeof func, () => Promise<Error | number>>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. const result = await func(); type test2 = Expect<Equal<typeof result, Error | number>>; }); it("should return the result if the function succeeds", async () => { const func = safeFunction((name: string) => { return Promise.resolve(`hello ${name}`); }); type test1 = Expect< Equal<typeof func, (name: string) => Promise<Error | string>>
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. >; const result = await func("world"); type test2 = Expect<Equal<typeof result, string | Error>>; expect(result).toEqual("hello world"); });

Update the types of the function and the generic type, and make these tests pass successfully.

Exercise 6: Multiple Type Arguments in a Generic Function

Exercise 7: Assertion Functions

This exercise starts with an interface User, which has properties id and name. Then we have an interface AdminUser, which extends User, inheriting all its properties and adding a roles string array property:

interface User {
  id: string;
  name: string;
}

interface AdminUser extends User {
  roles: string[];
}

The function assertIsAdminUser accepts either a User or AdminUser object as an argument. If the roles property isn't present in the argument, the function throws an error:

function assertIsAdminUser(user: User | AdminUser) {
  if (!("roles" in user)) {
    throw new Error("User is not an admin");
  }
}

This function's purpose is to verify we are able to access properties that are specific to the AdminUser, such as roles.

In the handleRequest function, we call assertIsAdminUser and expect the type of user to be narrowed down to AdminUser.

But as seen in this test case, it doesn't work as expected:

const handleRequest = (user: User | AdminUser) => {
  type test1 = Expect<Equal<typeof user, User | AdminUser>>;

  assertIsAdminUser(user);

  type test2 = Expect<Equal<typeof user, AdminUser>>;
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. user.roles;
Property 'roles' does not exist on type 'User | AdminUser'. Property 'roles' does not exist on type 'User'.2339
Property 'roles' does not exist on type 'User | AdminUser'. Property 'roles' does not exist on type 'User'.};

The user type is User | AdminUser before assertIsAdminUser is called, but it doesn't get narrowed down to just AdminUser after the function is called. This means we can't access the roles property.

Your task is to update the assertIsAdminUser function with the proper type assertion so that the user is identified as an AdminUser after the function is called.

Exercise 8: Assertion Functions

Solution 1: Make a Function Generic

The first thing we'll do to make this function generic is to add a type parameter T:

const createStringMap = <T>() => {
  return new Map();
};

With this change, our createStringMap function can now handle a type argument T.

The error has disappeared from the numberMap variable, but the function is still returning a Map<any, any>:

const numberMap = createStringMap<number>();

// hovering over createStringMap shows:
const createStringMap: <number>() => Map<any, any>;

We need to specify the types for the map entries.

Since we know that the keys will always be strings, we'll set the first type argument of Map to string. For the values, we'll use our type parameter T:

const createStringMap = <T>() => {
  return new Map<string, T>();
};

Now the function can correctly type the map's values.

If we don't pass in a type argument, the function will default to unknown:

const objMap = createStringMap();

// hovering over objMap shows:
const objMap: Map<string, unknown>;

Through these steps, we've successfully transformed createStringMap from a regular function into a generic function capable of receiving type arguments .

Solution 2: Default Type Arguments

The syntax for setting default types for generic functions is the same as for generic types:

const createStringMap = <T = string>() => {
  return new Map<string, T>();
};

By using the T = string syntax, we tell the function that if no type argument is supplied, it should default to string.

Now when we call createStringMap() without a type argument, we end up with a Map where both keys and values are string:

const stringMap = createStringMap();

// hovering over stringMap shows:
const stringMap: Map<string, string>;

If we attempt to assign a number as a value, TypeScript gives us an error because it expects a string:

stringMap.set("bar", 123);
Argument of type 'number' is not assignable to parameter of type 'string'.2345
Argument of type 'number' is not assignable to parameter of type 'string'.

However, we can still override the default type by providing a type argument when calling the function:

const numberMap = createStringMap<number>();
numberMap.set("foo", 123);

In the above code, numberMap will result in a Map with string keys and number values, and TypeScript will give an error if we try assigning a non-number value:

numberMap.set(
  "bar",
  // @ts-expect-error
  true,
);

Solution 3: Inference in Generic Functions

The first step is to add a type parameter onto uniqueArray. This turns uniqueArray into a generic function that can receive type arguments:

const uniqueArray = <T>(arr: any[]) => {
  return Array.from(new Set(arr));
};

Now when we hover over a call to uniqueArray, we can see that it is inferring the type as unknown:

const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
const uniqueArray: <unknown>(arr: any[]) => any[]

This is because we haven't passed any type arguments to it. If there's no type argument and no default, it defaults to unknown.

We want the type argument to be inferred as a number because we know that the thing we're getting back is an array of numbers.

So what we'll do is add a return type of T[] to the function:

const uniqueArray = <T>(arr: any[]): T[] => {
  return Array.from(new Set(arr));
};

Now the result of uniqueArray is inferred as an unknown array:

const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
const result: unknown[]

Again, the reason for this is that we haven't passed any type arguments to it. If there's no type argument and no default, it defaults to unknown.

If we add a <number> type argument to the call, the result will now be inferred as a number array:

const result = uniqueArray<number>([1, 1, 2, 3, 4, 4, 5]);
const result: number[]

However, at this point there's no relationship between the things we're passing in and the thing we're getting out. Adding a type argument to the call returns an array of that type, but the arr parameter in the function itself is still typed as any[].

What we need to do is tell TypeScript that the type of the arr parameter is the same type as what is passed in.

To do this, we'll replace arr: any[] with arr: T[]:

const uniqueArray = <T>(arr: T[]): T[] => {
  ...

The function's return type is an array of T, where T represents the type of elements in the array supplied to the function.

Thus, TypeScript can infer the return type as number[] for an input array of numbers, or string[] for an input array of strings, even without explicit return type annotations. As we can see, the tests pass successfully:

// number test
const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);

type test = Expect<Equal<typeof result, number[]>>;

// string test
const result = uniqueArray(["a", "b", "b", "c", "c", "c"]);

type test = Expect<Equal<typeof result, string[]>>;

If you explicitly pass a type argument, TypeScript will use it. If you don't, TypeScript attempts to infer it from the runtime arguments.

Solution 4: Type Parameter Constraints

The syntax to add constraints is the same as what we saw for generic types.

We need to use the extends keyword to add constraints to the generic type parameter TError. The object passed in is required to have a message property of type string, and can optionally have a code of type number:

const UNKNOWN_CODE = 8000;

const addCodeToError = <TError extends { message: string; code?: number }>(
  error: TError,
) => {
  return {
    ...error,
    code: error.code ?? UNKNOWN_CODE,
  };
};

This change ensures that addCodeToError must be called with an object that includes a message string property. TypeScript also knows that code could either be a number or undefined. If code is absent, it will default to UNKNOWN_CODE.

These constraints make our tests pass, including the case where we pass in an extra filepath property. This is because using extends in generics does not restrict you to only passing in the properties defined in the constraint.

Solution 5: Combining Generic Types and Functions

Here's the starting point of our safeFunction:

type PromiseFunc = () => Promise<any>;

const safeFunction = (func: PromiseFunc) => async () => {
  try {
    const result = await func();
    return result;
  } catch (e) {
    if (e instanceof Error) {
      return e;
    }
    throw e;
  }
};

The first thing we'll do is update the PromiseFunc type to be a generic type. We'll call the type parameter TResult to represent the type of the value returned by the promise, and and it to the return type of the function:

type PromiseFunc<TResult> = () => Promise<TResult>;

With this update, we now need to update the PromiseFunc in the safeFunction to include the type argument:

const safeFunction =
  <TResult>(func: PromiseFunc<TResult>) =>
  async () => {
    try {
      const result = await func();
      return result;
    } catch (e) {
      if (e instanceof Error) {
        return e;
      }
      throw e;
    }
  };

With these changes in place, when we hover over the safeFunction call in the first test, we can see that the type argument is inferred as number as expected:

it("should return an error if the function throws", async () => {
  const func = safeFunction(async () => {
    if (Math.random() > 0.5) {
      throw new Error("Something went wrong");
    }
    return 123;
  });
  ...

// hovering over safeFunction shows:
const safeFunction: <number>(func: PromiseFunc<number>) => Promise<() => Promise<number | Error>>

The other tests pass as well.

Whatever we pass into safeFunction will be inferred as the type argument for PromiseFunc. This is because the type argument is being inferred inside the generic function.

This combination of generic functions and generic types can make your generic functions a lot easier to read.

Solution 6: Multiple Type Arguments in a Generic Function

Here's how PromiseFunc is currently defined:

type PromiseFunc<TResult> = (...args: any[]) => Promise<TResult>;

The first thing to do is figure out the types of the arguments being passed in. Currently, they're set to one value, but they need to be different based on the type of function being passed in.

Instead of having args be of type any[], we want to spread in all of the args and capture the entire array.

To do this, we'll update the type to be TArgs. Since args needs to be an array, we'll say that TArgs extends any[]. Note that this doesn't mean that TArgs will be typed as any, but rather that it will accept any kind of array:

type PromiseFunc<TArgs extends any[], TResult> = (
  ...args: TArgs
) => Promise<TResult>;

You might have tried this with unknown[] - but any[] is the only thing that works in this scenario.

Now we need to update the safeFunction so that it has the same arguments as PromiseFunc. To do this, we'll add TArgs to its type parameters.

Note that we also need to update the args for the async function to be of type TArgs:

const safeFunction =
  <TArgs extends any[], TResult>(func: PromiseFunc<TArgs, TResult>) =>
  async (...args: TArgs) => {
    try {
      const result = await func(...args);
      return result;
    } catch (e) {
      ...

This change is necessary in order to make sure the function returned by safeFunction has the same typed arguments as the original function.

With these changes, all of our tests pass as expected.

Solution 7: Assertion Functions

The solution is to add a type annotation onto the return type of assertIsAdminUser.

If it was a type predicate, we would say user is AdminUser:

function assertIsAdminUser(user: User): user is AdminUser {
A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.2355
A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value. if (!("roles" in user)) { throw new Error("User is not an admin"); } }

However, this leads to an error. We get this error because assertIsAdminUser is returning void, which is different from a type predicate that requires you to return a Boolean.

Instead, we need to add the asserts keyword to the return type:

function assertIsAdminUser(user: User | AdminUser): asserts user is AdminUser {
  if (!("roles" in user)) {
    throw new Error("User is not an admin");
  }
}

By adding the asserts keyword, just by the fact that assertIsAdminUser is called we can assert that the user is an AdminUser. We don't need to put it inside an if statement or anywhere else.

With the asserts change in place, the user type is narrowed down to AdminUser after assertIsAdminUser is called and the test passes as expected:

const handleRequest = (user: User | AdminUser) => {
  type test1 = Expect<Equal<typeof user, User | AdminUser>>;

  assertIsAdminUser(user);

  type test2 = Expect<Equal<typeof user, AdminUser>>;

  user.roles;
};

// hovering over roles shows:
user: AdminUser;

Want to become a TypeScript wizard?

Unlock Pro Essentials
TypeScript Pro Essentials
PreviousDesigning Your Types