Chapter 15

Designing Your Types

Explore TypeScript generics, template literal types, conditional and mapped types, with practical exercises. Master advanced type manipulations and constraints.

15

As you build out your TypeScript applications, you're going to notice something. The way you design your types will significantly change how easy your application is to maintain.

Your types are more than just a way to catch errors at compile time. They help reflect and communicate the business logic they represent.

We've seen syntax like interface extends and type helpers like Pick and Omit. We understand the benefits and trade-offs of deriving types from other types. In this chapter, we'll dive deeper into designing your types in TypeScript.

We'll add several more techniques for composing and transforming types. We'll work with generic types, which can turn your types into 'type functions'. We'll also introduce template literal types for defining and enforcing specific string formats, as well as mapped types for deriving the shape of one type from another.

Generic Types

Generic types let you turn a type into a 'type function' which can receive arguments. We've seen generic types before, like Pick and Omit. These types take in a type and a key, and return a new type based on that key:

type Example = Pick<{ a: string; b: number }, "a">;

Now, we're going to be creating our own generic types. These are most useful for reducing repetition in your code.

Consider these StreamingPlaylist and StreamingAlbum types, which share similar structures:

type StreamingPlaylist =
  | {
      status: "available";
      content: {
        id: number;
        name: string;
        tracks: string[];
      };
    }
  | {
      status: "unavailable";
      reason: string;
    };

type StreamingAlbum =
  | {
      status: "available";
      content: {
        id: number;
        title: string;
        artist: string;
        tracks: string[];
      };
    }
  | {
      status: "unavailable";
      reason: string;
    };

Both of these types represent a streaming resource that is either available with specific content or unavailable with a reason for its unavailability.

The primary difference lies in the structure of the content object: the StreamingPlaylist type has a name property, while the StreamingAlbum type has a title and artist property. Despite this difference, the overall structure of the types is the same.

In order to reduce repetition, we can create a generic type called ResourceStatus that can represent both StreamingPlaylist and StreamingAlbum.

To create a generic type, we use a type parameter that declares what type of arguments the type must receive.

To specify the parameter, we use the angle bracket syntax that will look familiar from working with the various type helpers we've seen earlier in the book:

type ResourceStatus<TContent> = unknown;

Our ResourceStatus type will take in a type parameter of TContent, which will represent the shape of the content object that is specific to each resource. For now, we'll set the resolved type to unknown.

Often type parameters are named with single-letter names like T, K, or V, but you can name them anything you like.

Now we've declared ResourceStatus as a generic type, we can pass it a type argument.

Let's create an Example type, and provide an object type as the type argument for TContent:

type Example = ResourceStatus<{
  id: string;
  name: string;
  tracks: string[];
}>;

Just like with Pick and Omit, the type argument is passed in as an argument to the generic type.

But what type will Example be?

// hovering over Example shows
type Example = unknown;

We set the result of ResourceStatus to be unknown. Why is this happening? We can get a clue by hovering over the TContent parameter in the ResourceStatus type:

type ResourceStatus<TContent> = unknown;

// hovering over TContent shows:
// Type 'TContent' is declared but its value is never read.

We're not using the TContent parameter. We're just returning unknown, no matter what is passed in. So, the Example type is also unknown.

So, let's use it. Let's update the ResourceStatus type to match the structure of the StreamingPlaylist and StreamingAlbum types, with the bit we want to be dynamic replaced with the TContent type parameter:

type ResourceStatus<TContent> =
  | {
      status: "available";
      content: TContent;
    }
  | {
      status: "unavailable";
      reason: string;
    };

We can now redefine StreamingPlaylist and StreamingAlbum to use it:

type StreamingPlaylist = ResourceStatus<{
  id: number;
  name: string;
  tracks: string[];
}>;

type StreamingAlbum = ResourceStatus<{
  id: number;
  title: string;
  artist: string;
  tracks: string[];
}>;

Now if we hover over StreamingPlaylist, we will see that it has the same structure as it did originally, but it's now defined with the ResourceStatus type without having to manually provide the additional properties:

// hovering over StreamingPlaylist shows:

type StreamingPlaylist =
  | {
      status: "unavailable";
      reason: string;
    }
  | {
      status: "available";
      content: {
        id: number;
        name: string;
        tracks: string[];
      };
    };

ResourceStatus is now a generic type. It's a kind of type function, which means it's useful in all the ways runtime functions are useful. We can use generic types to capture repeated patterns in our types, and make our types more flexible and reusable.

Multiple Type Parameters

Generic types can accept multiple type parameters, allowing for even more flexibility.

We could expand the ResourceStatus type to include a second type parameter that represents metadata that accompanies the resource:

type ResourceStatus<TContent, TMetadata> =
  | {
      status: "available";
      content: TContent;
      metadata: TMetadata;
    }
  | {
      status: "unavailable";
      reason: string;
    };

Now we can define the StreamingPlaylist and StreamingAlbum types, we can include metadata specific to each resource:

type StreamingPlaylist = ResourceStatus<
  {
    id: number;
    name: string;
    tracks: string[];
  },
  {
    creator: string;
    artwork: string;
    dateUpdated: Date;
  }
>;

type StreamingAlbum = ResourceStatus<
  {
    id: number;
    title: string;
    artist: string;
    tracks: string[];
  },
  {
    recordLabel: string;
    upc: string;
    yearOfRelease: number;
  }
>;

Like before, each type maintains the same structure defined in ResourceStatus, but with its own content and metadata.

You can use as many type parameters as you need in a generic type. But just like with functions, the more parameters you have, the more complex your types can become.

All Type Arguments Must Be Provided

What happens if we don't pass a type argument to a generic type? Let's try it with the ResourceStatus type:

type Example = ResourceStatus;
Generic type 'ResourceStatus' requires 2 type argument(s).2314
Generic type 'ResourceStatus' requires 2 type argument(s).

TypeScript shows an error that tells us that ResourceStatus requires two type arguments. This is because by default, all generic types require their type arguments to be passed in, just like runtime functions.

Default Type Parameters

In some cases, you may want to provide default types for generic type parameters. Like with functions, you can use the = to assign a default value.

By setting TMetadata's default value to an empty, we can essentially make TMetadata optional:

type ResourceStatus<TContent, TMetadata = {}> =
  | {
      status: "available";
      content: TContent;
      metadata: TMetadata;
    }
  | {
      status: "unavailable";
      reason: string;
    };

Now, we can create a StreamingPlaylist type without providing a TMetadata type argument:

type StreamingPlaylist = ResourceStatus<{
  id: number;
  name: string;
  tracks: string[];
}>;

If we hover over it, we'll see that it's typed as expected, with metadata being an empty object:

type StreamingPlaylist =
  | {
      status: "unavailable";
      reason: string;
    }
  | {
      status: "available";
      content: {
        id: number;
        name: string;
        tracks: string[];
      };
      metadata: {};
    };

Defaults can help make your generic types more flexible and easier to use.

Type Parameter Constraints

To set constraints on type parameters, we can use the extends keyword.

We can force the TMetadata type parameter to be an object while still defaulting to an empty object:

type ResourceStatus<TContent, TMetadata extends object = {}> = // ...

There's also an opportunity to provide a constraint for the TContent type parameter.

Both of the StreamingPlaylist and StreamingAlbum types have an id property in their content objects. This would be a good candidate for a constraint.

We can create a HasId type that enforces the presence of an id property:

type HasId = {
  id: number;
};

type ResourceStatus<TContent extends HasId, TMetadata extends object = {}> =
  | {
      status: "available";
      content: TContent;
      metadata: TMetadata;
    }
  | {
      status: "unavailable";
      reason: string;
    };

With these changes in place, it is now required that the TContent type parameter must include an id property. The TMetadata type parameter is optional, but if it is provided it must be an object.

When we try to create a type with ResourceStatus that doesn't have an id property, TypeScript will raise an error that tells us exactly what's wrong:

type StreamingPlaylist = ResourceStatus<
  {
Type '{ name: string; tracks: string[]; }' does not satisfy the constraint 'HasId'. Property 'id' is missing in type '{ name: string; tracks: string[]; }' but required in type 'HasId'.2344
Type '{ name: string; tracks: string[]; }' does not satisfy the constraint 'HasId'. Property 'id' is missing in type '{ name: string; tracks: string[]; }' but required in type 'HasId'. name: string; tracks: string[]; }, { creator: string; artwork: string; dateUpdated: Date; } >;

Once the id property is added to the TContent type parameter, the error will go away.

Constraints Describe Required Properties

Note that these constraints we're providing here are just descriptions for properties the object must contain. We can pass name and tracks into TContent as long as it has an id property.

In other words, these constraints are open, not closed. You won't get excess property warnings here. Any excess properties you pass in will be added to the type.

extends, extends, extends

By now, we've seen extends used in a few different contexts:

  • In generic types, to set constraints on type parameters
  • In classes, to extend another class
  • In interfaces, to extend another interface

There is even another use for extends - conditional types, which we'll look at later in this chapter.

One of TypeScript's annoying habits is that it tends to reuse the same keywords in different contexts. So it's important to understand that extends means different things in different places.

Template Literal Types in TypeScript

Similar to how template literals in JavaScript allow you to interpolate values into strings, template literal types in TypeScript can be used to interpolate other types into string types.

For example, let's create a PngFile type that represents a string that ends with ".png":

type PngFile = `${string}.png`;

Now when we type a new variable as PngFile, it must end with ".png":

let myImage: PngFile = "my-image.png"; // OK

When a string does not match the pattern defined in the PngFile type, TypeScript will raise an error:

let myImage: PngFile = "my-image.jpg";
Type '"my-image.jpg"' is not assignable to type '`${string}.png`'.2322
Type '"my-image.jpg"' is not assignable to type '`${string}.png`'.

Enforce specific string formats with template literal types can be useful in your applications.

Combining Template Literal Types with Union Types

Template literal types become even more powerful when combined with union types. By passing a union to a template literal type, you can generate a type that represents all possible combinations of the union.

For example, let's imagine we have a set of colors each with possible shades from 100 to 900:

type ColorShade = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type Color = "red" | "blue" | "green";

If we want a combination of all possible colors and shades, we can use a template literal type to generate a new type:

type ColorPalette = `${Color}-${ColorShade}`;

Now, ColorPalette will represent all possible combinations of colors and shades:

let myColor: ColorPalette = "red-500"; // OK
let myColor2: ColorPalette = "blue-900"; // OK

That's 27 possible combinations - three colors times nine shades.

If you have any kind of string pattern you want to enforce in your application, from routes to URI's to hex codes, template literal types can help.

Transforming String Types

TypeScript even has several built-in utility types for transforming string types. For example, Uppercase and Lowercase can be used to convert a string to uppercase or lowercase:

type UppercaseHello = Uppercase<"hello">;
type UppercaseHello = "HELLO"
type LowercaseHELLO = Lowercase<"HELLO">;
type LowercaseHELLO = "hello"

The Capitalize type can be used to capitalize the first letter of a string:

type CapitalizeMatt = Capitalize<"matt">;
type CapitalizeMatt = "Matt"

The Uncapitalize type can be used to lowercase the first letter of a string:

type UncapitalizePHD = Uncapitalize<"PHD">;
type UncapitalizePHD = "pHD"

These utility types are occasionally useful for transforming string types in your applications, and prove how flexible TypeScript's type system can be.

Conditional Types

You can use conditional types in TypeScript to create if/else logic in your types. This is mostly useful in a library setting when working with really complex code, but I'll show you a simple example in case you ever run into it.

Let's imagine we create a ToArray generic type that converts a type to an array type:

type ToArray<T> = T[];

This is fine, except when we pass in a type that's already an array. If we do, we'll get an array of arrays:

type Example = ToArray<string>;
type Example = string[]
type Example2 = ToArray<string[]>;
type Example2 = string[][]

We actually want Example2 to end up as string[] too. So, we'll need to check if T is already an array, and if it is, we'll return T instead of T[].

We can use a conditional type to do that. This uses a ternary operator, similar to JavaScript:

type ToArray<T> = T extends any[] ? T : T[];

This will look pretty scary the first time you see it, but let's break it down.

type ToArray<T> = T extends any[] ? T : T[];
//                ^^^^^^^^^^^^^^^   ^   ^^^
//                condition       true/false

The Condition

The 'condition' in a conditional type is the part before the ?. In this case, it's T extends any[].

type ToArray<T> = T extends any[] ? T : T[];
//                ^^^^^^^^^^^^^^^
//                   condition

This checks if T can be assigned to any[]. To make sense of this check, imagine it like a function:

const toArray = (t: any[]) => {
  // implementation
};

What could be passed to this function? Only arrays:

toArray([1, 2, 3]); // OK
toArray("hello");
Argument of type 'string' is not assignable to parameter of type 'any[]'.2345
Argument of type 'string' is not assignable to parameter of type 'any[]'.

T extends any[] checks if T could be passed to a function expecting any[]. If we wanted to check if T was a string, we'd use T extends string.

'True' and 'False'

type ToArray<T> = T extends any[] ? T : T[];
//                                  ^   ^^^
//                                 true/false

If the condition is true, it resolves to the 'true' part, just like a normal ternary. If it's false, it resolves to the 'false' part.

In this case, if T is an array, it resolves to T. If it's not, it resolves to T[].

This means that our examples above now work as expected:

type Example = ToArray<string>;
type Example = string[]
type Example2 = ToArray<string[]>;
type Example2 = string[]

Conditional types turn TypeScript's type system into a full programming language. They're incredibly powerful, but they can also be incredibly complex. You'll rarely need them in application code, but they can perform wonders in library code.

Mapped Types

Mapped types in TypeScript allow you to create a new object type based on an existing type by iterating over its keys and values. This can be let you be extremely expressive when creating new object types.

Consider this Album interface:

interface Album {
  name: string;
  artist: string;
  songs: string[];
}

Imagine we want to create a new type that makes all the properties optional and nullable. If it were only optional, we could use Partial, but we want to end up with a type that looks like this:

type AlbumWithNullable = {
  name?: string | null;
  artist?: string | null;
  songs?: string[] | null;
};

Let's start by, instead of repeating the properties, using a mapped type:

type AlbumWithNullable = {
  [K in keyof Album]: K;
};

This will look similar to an index signature, but instead of [k: string], we use [K in keyof Album]. This will iterate over each key in Album, and create a property in the object with that key. K is a name we've chosen: you can choose any name you like.

In this case, we're then using K as the value of the property, too. This is not what we want eventually, but it's a good start:

// Hovering over AlbumWithNullable shows:
type AlbumWithNullable = {
  name: "name";
  artist: "artist";
  songs: "songs";
};

We can see that K represents the currently iterated key. This means we can use it to get the type of the original Album property using an indexed access type:

type AlbumWithNullable = {
  [K in keyof Album]: Album[K];
};

// Hovering over AlbumWithNullable shows:
type AlbumWithNullable = {
  name: string;
  artist: string;
  songs: string[];
};

Wonderful - we've now recreated the object type of Album. Now we can add | null to each property:

type AlbumWithNullable = {
  [K in keyof Album]: Album[K] | null;
};

// Hovering over AlbumWithNullable shows:
type AlbumWithNullable = {
  name: string | null;
  artist: string | null;
  songs: string[] | null;
};

This is almost there, we just need to make each property optional. We can do this by adding a ? after the key:

type AlbumWithNullable = {
  [K in keyof Album]?: Album[K] | null;
};

// Hovering over AlbumWithNullable shows:
type AlbumWithNullable = {
  name?: string | null;
  artist?: string | null;
  songs?: string[] | null;
};

Now, we have a new type that is derived from the Album type, but with all properties optional and nullable.

In the spirit of designing our types properly, we should make this behavior reusable by wrapping it in a generic type, Nullable<T>:

type Nullable<T> = {
  [K in keyof T]?: T[K] | null;
};

type AlbumWithNullable = Nullable<Album>;

Mapped types are an extremely useful method for transforming object types, and have many different uses in application code.

Key Remapping with as

In the previous example, we didn't need to change the key of the object we were iterating over. But what if we did?

Let's say we want to create a new type that has the same properties as Album, but with the key names uppercased. We could try using Uppercase on keyof Album:

type AlbumWithUppercaseKeys = {
  [K in Uppercase<keyof Album>]: Album[K];
Type 'K' cannot be used to index type 'Album'.2536
Type 'K' cannot be used to index type 'Album'.};

But this doesn't work. We can't use K to index Album because K has already been transformed to its uppercase version. Instead, we need to find a way to keep K the same as before, while using the uppercase version of K for the key.

We can do this by using the as keyword to remap the key:

type AlbumWithUppercaseKeys = {
  [K in keyof Album as Uppercase<K>]: Album[K];
};

// Hovering over AlbumWithUppercaseKeys shows:
type AlbumWithUppercaseKeys = {
  NAME: string;
  ARTIST: string;
  SONGS: string[];
};

as allows us to remap the key while keeping the original key accessible in the loop. This isn't like when we use as for a type assertion - it's a completely different use of the keyword.

Using Mapped Types with Union Types

Mapped types don't always have to use keyof to iterate over an object. They can also map over a union of potential property keys for the object.

For example, we can create an Example type that is a union of 'a', 'b', and 'c':

type Example = "a" | "b" | "c";

Then, we can create a MappedExample type that maps over Example and returns the same values:

type MappedExample = {
  [E in Example]: E;
};

// hovering over MappedExample shows:
type MappedExample = {
  a: "a";
  b: "b";
  c: "c";
};

This chapter should give you a good understanding of advanced methods for designing your types in TypeScript. By using generic types, template literal types, conditional types, and mapped types, you can create expressive and reusable types that reflect the business logic of your application.

Exercises

Exercise 1: Create a DataShape Type Helper

Consider the types UserDataShape and PostDataShape:

type ErrorShape = {
  error: {
    message: string;
  };
};

type UserDataShape =
  | {
      data: {
        id: string;
        name: string;
        email: string;
      };
    }
  | ErrorShape;

type PostDataShape =
  | {
      data: {
        id: string;
        title: string;
        body: string;
      };
    }
  | ErrorShape;

Looking at these types, they both share a consistent pattern. Both UserDataShape and PostDataShape possess a data object and an error shape, with the error shape being identical in both. The only difference between the two is the data object, which holds different properties for each type.

Your task is to create a generic DataShape type to reduce duplication in the UserDataShape and PostDataShape types.

Exercise 1: Create a DataShape Type Helper

Exercise 2: Typing PromiseFunc

This PromiseFunc type represents a function that returns a promise:

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

Provided here are two example tests that use the PromiseFunc type with different inputs that currently have errors:

type Example1 = PromiseFunc<string, string>;
Type 'PromiseFunc' is not generic.2315
Type 'PromiseFunc' is not generic. type test1 = Expect<Equal<Example1, (input: string) => Promise<string>>>; type Example2 = PromiseFunc<boolean, number>;
Type 'PromiseFunc' is not generic.2315
Type 'PromiseFunc' is not generic. type test2 = Expect<Equal<Example2, (input: boolean) => Promise<number>>>;

The error messages inform us that the PromiseFunc type is not generic. We're also expecting the PromiseFunc type to take in two type arguments: the input type and the return type of the promise.

Your task is to update PromiseFunc so that both of the tests pass without errors.

Exercise 2: Typing PromiseFunc

Exercise 3: Working with the Result Type

Let's say we have a Result type that can either be a success or an error:

type Result<TResult, TError> =
  | {
      success: true;
      data: TResult;
    }
  | {
      success: false;
      error: TError;
    };

We also have the createRandomNumber function that returns a Result type:

const createRandomNumber = (): Result<number> => {
Generic type 'Result' requires 2 type argument(s).2314
Generic type 'Result' requires 2 type argument(s). const num = Math.random(); if (num > 0.5) { return { success: true, data: 123, }; } return { success: false, error: new Error("Something went wrong"), }; };

Because there's only a number being sent as a type argument, we have an error message. We're only specifying the number because it can be a bit of a hassle to always specify both the success and error types whenever we use the Result type.

It would be easier if we could designate Error type as the default type for Result's TError, since that's what most errors will be typed as.

Your task is to adjust the Result type so that TError defaults to type Error.

Exercise 3: Working with the Result Type

Exercise 4: Constraining the Result Type

After updating the Result type to have a default type for TError, it would be a good idea to add a constraint on the shape of what's being passed in.

Here are some examples of using the Result type:

type BadExample = Result<
  { id: string },
  // @ts-expect-error Should be an object with a message property
Unused '@ts-expect-error' directive.2578
Unused '@ts-expect-error' directive. string >; type GoodExample = Result<{ id: string }, TypeError>; type GoodExample2 = Result<{ id: string }, { message: string; code: number }>; type GoodExample3 = Result<{ id: string }, { message: string }>; type GoodExample4 = Result<{ id: string }>;

The GoodExamples should pass without errors, but the BadExample should raise an error because the TError type is not an object with a message property. Currently, this isn't the case as seen by the error under the @ts-expect-error directive.

Your task is to add a constraint to the Result type that ensures the BadExample test raises an error, while the GoodExamples pass without errors.

Exercise 4: Constraining the Result Type

Exercise 5: A Stricter Omit Type

Earlier in the book, we worked with the Omit type helper, which allows you to create a new type that omits specific properties from an existing type.

Interestingly, the Omit helper also lets you try to omit keys that don't exist in the type you're trying to omit from.

In this example, we're trying to omit the property b from a type that only has the property a:

type Example = Omit<{ a: string }, "b">;

Since b isn't a part of the type, you might anticipate TypeScript would show an error, but it doesn't.

Instead, we want to implement a StrictOmit type that only accepts keys that exist in the provided type. Otherwise, an error should be shown.

Here's the start of StrictOmit, which currently has an error under K:

type StrictOmit<T, K> = Omit<T, K>;
Type 'K' does not satisfy the constraint 'string | number | symbol'.2344
Type 'K' does not satisfy the constraint 'string | number | symbol'.

Currently, the StrictOmit type behaves the same as a regular Omit as evidenced by this failing @ts-expect-error directive:

type ShouldFail = StrictOmit<
  { a: string },
  // @ts-expect-error
Unused '@ts-expect-error' directive.2578
Unused '@ts-expect-error' directive. "b" >;

Your task is to update StrictOmit so that it only accepts keys that exist in the provided type T. If a non-valid key K is passed, TypeScript should return an error.

Here are some tests to show how StrictOmit should behave:

type tests = [
  Expect<Equal<StrictOmit<{ a: string; b: number }, "b">, { a: string }>>,
  Expect<Equal<StrictOmit<{ a: string; b: number }, "b" | "a">, {}>>,
  Expect<
    Equal<StrictOmit<{ a: string; b: number }, never>, { a: string; b: number }>
  >,
];

You'll need to remember keyof and how to constraint type parameters to complete this exercise.

Exercise 5: A Stricter Omit Type

Exercise 6: Route Matching

Here we have a route typed as AbsoluteRoute:

type AbsoluteRoute = string;

const goToRoute = (route: AbsoluteRoute) => {
  // ...
};

We're expecting that the AbsoluteRoute will represent any string that has a forward slash at the start of it. For example, we'd expect the following strings to be valid routes:

goToRoute("/home");
goToRoute("/about");
goToRoute("/contact");

However, if we attempt passing a string that doesn't start with a forward slash there currently is not an error:

goToRoute(
  // @ts-expect-error
Unused '@ts-expect-error' directive.2578
Unused '@ts-expect-error' directive. "somewhere", );

Because AbsoluteRoute is currently typed as string, TypeScript fails to flag this as an error.

Your task is to refine the AbsoluteRoute type to accurately expect that strings begin with a forward slash.

Exercise 6: Route Matching

Exercise 7: Sandwich Permutations

In this exercise, we want to build a Sandwich type.

This Sandwich is expected to encompass all possible combinations of three types of bread ("rye", "brown", "white") and three types of filling ("cheese", "ham", "salami"):

type BreadType = "rye" | "brown" | "white";

type Filling = "cheese" | "ham" | "salami";

type Sandwich = unknown;

As seen in the test, there are several possible combinations of bread and filling:

type tests = [
  Expect<
    Equal<
      Sandwich,
      | "rye sandwich with cheese"
      | "rye sandwich with ham"
      | "rye sandwich with salami"
      | "brown sandwich with cheese"
      | "brown sandwich with ham"
      | "brown sandwich with salami"
      | "white sandwich with cheese"
      | "white sandwich with ham"
      | "white sandwich with salami"
    >
  >,
];

Your task is to use a template literal type to define the Sandwich type. This can be done in just one line of code!

Exercise 7: Sandwich Permutations

Exercise 8: Attribute Getters

Here we have an Attributes interface, that contains a firstName, lastName, and age:

interface Attributes {
  firstName: string;
  lastName: string;
  age: number;
}

Following that, we have AttributeGetters which is currently typed as unknown:

type AttributeGetters = unknown;

As seen in the tests, we expect AttributeGetters to be an object composed of functions. When invoked, each of these functions should return a value matching the key from the Attributes interface:

type tests = [
  Expect<
    Equal<
Type 'false' does not satisfy the constraint 'true'.2344
Type 'false' does not satisfy the constraint 'true'. AttributeGetters, { firstName: () => string; lastName: () => string; age: () => number; } > >, ];

Your task is to define the AttributeGetters type so that it matches the expected output. To do this, you'll need to iterate over each key in Attributes and produce a function as a value which then returns the value of that key.

Exercise 8: Attribute Getters

Exercise 9: Renaming Keys in a Mapped Type

After creating the AttributeGetters type in the previous exercise, it would be nice to rename the keys to be more descriptive.

Here's a test case for AttributeGetters that currently has an error:

type tests = [
  Expect<
    Equal<
      AttributeGetters,
      {
        getFirstName: () => string;
        getLastName: () => string;
        getAge: () => number;
      }
    >
  >,
];

Your challenge is to adjust the AttributeGetters type to remap the keys as specified. You'll need to use the as keyword, template literals, as well as TypeScript's built-in Capitalize<string> type helper.

Exercise 9: Renaming Keys in a Mapped Type

Solution 1: Create a DataShape Type Helper

Here's how a generic DataShape type would look:

type DataShape<TData> =
  | {
      data: TData;
    }
  | ErrorShape;

With this type defined, the UserDataShape and PostDataShape types can be updated to use it:

type UserDataShape = DataShape<{
  id: string;
  name: string;
  email: string;
}>;

type PostDataShape = DataShape<{
  id: string;
  title: string;
  body: string;
}>;

Solution 2: Typing PromiseFunc

The first thing we need to do is make PromiseFunc generic by adding type parameters to it.

In this case, since we want it to have two parameters we call them TInput and TOutput and separate them with a comma:

type PromiseFunc<TInput, TOutput> = (input: any) => Promise<any>;

Next, we need to replace the any types with the type parameters.

In this case, the input will use the TInput type, and the Promise will use the TOutput type:

type PromiseFunc<TInput, TOutput> = (input: TInput) => Promise<TOutput>;

With these changes in place, the errors go away and the tests pass because PromiseFunc is now a generic type. Any type passed as TInput will serve as the input type, and any type passed as TOutput will act as the output type of the Promise.

Solution 3: Working with the Result Type

Similar to other times you set default values, the solution is to use an equals sign.

In this case, we'll add the = after the TError type parameter, and then specify Error as the default type:

type Result<TResult, TError = Error> =
  | {
      success: true;
      data: TResult;
    }
  | {
      success: false;
      error: TError;
    };

Result types are a great way to ensure errors are handled properly. For instance, result here must be checked for success before accessing the data property:

const result = createRandomNumber();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.message);
}

This pattern can be a great alternative to try...catch blocks in JavaScript.

Solution 4: Constraining the Result Type

We want to set a constraint on TError to ensure that it is an object with a message string property, while also retaining Error as the default type for TError.

To do this, we'll use the extends keyword for TError and specify the object with a message property as the constraint:

type Result<TResult, TError extends { message: string } = Error> =
  | {
      success: true;
      data: TResult;
    }
  | {
      success: false;
      error: TError;
    };

Now if we remove the @ts-expect-error directive from BadExample, we will get an error under string:

type BadExample = Result<{ id: string }, string>;
Type 'string' does not satisfy the constraint '{ message: string; }'.2344
Type 'string' does not satisfy the constraint '{ message: string; }'.

The behavior of constraining type parameters and adding defaults is similar to runtime parameters. However, unlike runtime arguments, you can add additional properties and still satisfy the constraint:

type GoodExample2 = Result<{ id: string }, { message: string; code: number }>;

A runtime argument constraint would be limited only containing a message string property without any additional properties, so the above wouldn't work the same way.

Solution 5: A Stricter Omit Type

Here's the starting point of the StrictOmit type and the ShouldFail example that we need to fix:

type StrictOmit<T, K> = Omit<T, K>;
Type 'K' does not satisfy the constraint 'string | number | symbol'.2344
Type 'K' does not satisfy the constraint 'string | number | symbol'. type ShouldFail = StrictOmit< { a: string }, // @ts-expect-error
Unused '@ts-expect-error' directive.2578
Unused '@ts-expect-error' directive. "b" >;

Our goal is to update StrictOmit so that it only accepts keys that exist in the provided type T. If a non-valid key K is passed, TypeScript should return an error.

Since the ShouldFail type has a key of a, we'll start by updating the StrictOmit's K to extend a:

type StrictOmit<T, K extends "a"> = Omit<T, K>;

Removing the @ts-expect-error directive from ShouldFail will now show an error under "b":

type ShouldFail = StrictOmit<{ a: string }, "b">;
Type '"b"' does not satisfy the constraint '"a"'.2344
Type '"b"' does not satisfy the constraint '"a"'.

This shows us that the ShouldFail type is failing as expected.

However, we want to make this more dynamic by specifying that K will extend any key in the object T that is passed in.

We can do this by changing the constraint from "a" to keyof T:

type StrictOmit<T, K extends keyof T> = Omit<T, K>;

Now in the StrictOmit type, K is bound to extend the keys of T. This imposes a limitation on the type parameter K that it must belong to the keys of T.

With this change, all of the tests pass as expected with any keys that are passed in:

type tests = [
  Expect<Equal<StrictOmit<{ a: string; b: number }, "b">, { a: string }>>,
  Expect<Equal<StrictOmit<{ a: string; b: number }, "b" | "a">, {}>>,
  Expect<
    Equal<StrictOmit<{ a: string; b: number }, never>, { a: string; b: number }>
  >,
];

Solution 6: Route Matching

In order to specify that AbsoluteRoute is a string that begins with a forward slash, we'll use a template literal type.

Here's how we could create a type that represents a string that begins with a forward slash, followed by either "home", "about", or "contact":

type AbsoluteRoute = `/${"home" | "about" | "contact"}`;

With this setup our tests would pass, but we'd be limited to only those three routes.

Instead, we want to allow for any string that begins with a forward slash.

To do this, we can just use the string type inside of the template literal:

type AbsoluteRoute = `/${string}`;

With this change, the somewhere string will cause an error since it does not begin with a forward slash:

goToRoute(
  // @ts-expect-error
  "somewhere",
);

Solution 7: Sandwich Permutations

Following the pattern of the tests, we can see that the desired results are named:

bread "sandwich with" filling

That means we should pass the BreadType and Filling unions to the Sandwich template literal with the string "sandwich with" in between:

type BreadType = "rye" | "brown" | "white";
type Filling = "cheese" | "ham" | "salami";
type Sandwich = `${BreadType} sandwich with ${Filling}`;

TypeScript generates all the possible combinations, leading to the type Sandwich being:

| "rye sandwich with cheese"
| "rye sandwich with ham"
| "rye sandwich with salami"
| "brown sandwich with cheese"
| "brown sandwich with ham"
| "brown sandwich with salami"
| "white sandwich with cheese"
| "white sandwich with ham"
| "white sandwich with salami"

Solution 8: Attribute Getters

Our challenge is to derive the shape of AttributeGetters based on the Attributes interface:

interface Attributes {
  firstName: string;
  lastName: string;
  age: number;
}

To do this, we'll use a mapped type. We'll start by using [K in keyof Attributes] to iterate over each key in Attributes. Then, we'll create a new property for each key, which will be a function that returns the type of the corresponding property in Attributes:

type AttributeGetters = {
  [K in keyof Attributes]: () => Attributes[K];
};

The Attributes[K] part is the key to solving this challenge. It allows us to index into the Attributes object and return the actual values of each key.

With this approach, we get the expected output and all tests pass as expected:

type tests = [
  Expect<
    Equal<
      AttributeGetters,
      {
        firstName: () => string;
        lastName: () => string;
        age: () => number;
      }
    >
  >,
];

Solution 9: Renaming Keys in a Mapped Type

Our goal is to create a new mapped type, AttributeGetters, that changes each key in Attributes into a new key that begins with "get" and capitalizes the original key. For example, firstName would become getFirstName.

Before we get to the solution, let's look at an incorrect approach.

The Incorrect Approach

It might be tempting to think that you should transform keyof Attributes before it even gets to the mapped type.

To do this, we'd creating a NewAttributeKeys type and setting it to a template literal with keyof Attributes inside of it added to get:

type NewAttributeKeys = `get${keyof Attributes}`;

However, hovering over NewAttributeKeys shows that it's not quite right:

// hovering over NewAttributeKeys shows:
type NewAttributeKeys = "getfirstName" | "getlastName" | "getage";

Adding the global Capitalize helper results in the keys being formatted correctly:

type NewAttributeKeys = `get${Capitalize<keyof Attributes>}`;

Since we have formatted keys, we can now use NewAttributeKeys in the map type:

type AttributeGetters = {
  [K in NewAttributeKeys]: () => Attributes[K];
Type 'K' cannot be used to index type 'Attributes'.2536
Type 'K' cannot be used to index type 'Attributes'.};

However, we have a problem. We can't use K to index Attributes because each K has changed and no longer exists on Attributes.

We need to maintain access to the original key inside the mapped type.

The Correct Approach

In order to maintain access to the original key, we can use the as keyword.

Instead of using the NewAttributeKeys type we tried before, we can update the map type to use keyof Attributes as followed by the transformation we want to make:

type AttributeGetters = {
  [K in keyof Attributes as `get${Capitalize<K>}`]: () => Attributes[K];
};

We now iterate over each key K in Attributes, and use it in a template literal type that prefixes "get" and capitalizes the original key. Then the value paired with each new key is a function that returns the type of the original key in Attributes.

Now when we hover over the AttributeGetters type, we see that it's correct and the tests pass as expected:

// hovering over AttributeGetters shows:
type AttributeGetters = {
  getFirstName: () => string;
  getLastName: () => string;
  getAge: () => number;
};

Want to become a TypeScript wizard?

Unlock Pro Essentials
TypeScript Pro Essentials
PreviousConfiguring TypeScript
NextUtility Folder Development