Chapter 16

Utility Folder Development in TypeScript

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"}');
// Red squiggly line under { hello: string }
// 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) => void;
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) => void;
// ^^^
// Type parameter belongs to the function
// Generic type
type Identity<T> = (arg: T) => void;
// ^^^
// 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; // Red squiggly line under Array
// Hovering over Array shows:
// 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);
// Hovering over result shows:
// const result: 42

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


const result1 = identity("hello"); // result1: 'hello'
const result2 = identity({ hello: "world" }); // result2: { hello: 'world' }
const result3 = identity([1, 2, 3]); // 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); // Red squiggly line under 42
// Hovering over 42 shows:
// Argument of type '42' 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]);
// Hovering over first shows:
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 firstString = getFirstElement(["a", "b", "c"]);
// hovering over firstNumber shows:
const firstNumber: number | undefined;
// hovering over firstString shows:
const firstString: string | undefined;

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]);
// Hovering over getFirstElement shows:
// 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(); // 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]); // 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(); // 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; // red squiggly line under id
return rest;
};
// hovering over id shows:
// Property 'id' does not exist on type 'unknown'.

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" }); // red squiggly line under name
// hovering over name shows:
// 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" });
// hovering over result shows:
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 will infer lots of the values as unknown:


const run = (maybeAlbum: unknown) => {
if (isAlbum(maybeAlbum)) {
maybeAlbum.name.toUpperCase(); // red squiggly line under name
}
};
// hovering over name shows:
// Object is of type 'unknown'.

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) {
// 'item' is unknown here
assertIsAlbum(item);
// After the assertion, 'item' is narrowed to 'Album'
console.log(item.title);
}

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' is now narrowed to 'Album', even though it's
// null at runtime
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: {
// red squiggly line under searchMusic
artist: string;
genre: string;
year: number;
}): void;
// Hovering over searchMusic shows:
// Function implementation is missing or not immediately following the declaration.

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(
// red squiggly line under searchMusic
{
artist: "Tame Impala",
genre: "Psychedelic Rock",
year: 2015,
},
"Psychedelic Rock",
);
// Hovering over searchMusic shows:
// No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.

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>(); // red squiggly line under number
numberMap.set("foo", 123);
numberMap.set(
"bar",
// @ts-expect-error
true,
);

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


const objMap = createStringMap<{ a: number }>(); // red squiggly line under { a: number }
objMap.set("foo", { a: 123 });
objMap.set(
"bar",
// @ts-expect-error // red squiggly line under @ts-expect-error
{ 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>>>; // red squiggly line under Equal<>

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 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 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[]>>; // red squiggly line under Equal<>
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[]>>; // red squiggly line under Equal<>
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 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, // red squiggly line under code
};
};
// hovering over code shows
// 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 }>>; // red squiggly line under Equal<>
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<
typeof customErrorWithCode,
{
message: string;
code: number;
filepath: string;
} & {
code: number;
}
>
>; // red squiggly line under Equal<>
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 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>>>;
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(() => {
return Promise.resolve(`Hello!`);
});
type test1 = Expect<Equal<typeof func, () => Promise<string | Error>>>;
const result = await func();
type test2 = Expect<Equal<typeof result, string | Error>>;
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 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:


// inside of safeFunction
async (...args: any[]) => {
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>>
>; // red squiggly line under Equal<>

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>>>; // red squiggly line under Equal<>
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>>
>; // red squiggly line under Equal<>
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 8: 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>>; // red squiggly line under Equal<>
user.roles; // red squiggly line under roles
};

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.


// hovering over .roles shows:
Property 'roles' does not exist on type 'User | AdminUser'.

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.

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, // red squiggly line under 123
);

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]);
// hovering over uniqueArray shows:
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[] => {
...

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


const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
// hovering over uniqueArray shows:
const uniqueArray: <unknown>(arr: any[]) => 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]);
// hovering over uniqueArray shows:
const uniqueArray: <number>(arr: any[]) => 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 () => {
...

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 8: 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 {
// red squiggly line under user is AdminUser
if (!("roles" in user)) {
throw new Error("User is not an admin");
}
}

However, this leads to an error under user is AdminUser:


// hovering over user is AdminUser shows:
A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.

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;

Designing Your Types in TypeScript