Table of Contents
- TypeScript: The Savvy Sidekick of JavaScript
- Why Mapped Types Matter
- Mapped Types Syntax and Basic Plumbing
- A Deeper Dive!
- Tips, Tricks, and Best Practices: Unleashing the Magic Safely
- Wrapping it up
In this article, we’ll take a whirlwind tour through the fascinating world of TypeScript and its type system. Hold onto your hats and get ready to unleash the power of mapped types!
TypeScript: The Savvy Sidekick of JavaScript
Buckle up, folks! TypeScript swoops in like a suave sidekick, adding an extra layer of pizzazz to the world of JavaScript. With TypeScript, you get to catch those pesky bugs before they even think about causing chaos in your code.
It’s time to introduce you to the heroes of our story: mapped types! These are the true sorcerers of TypeScript especially when used in conjunction with keyof and conditional types. They wield the power to transform and shape types with a flick of their magical wands. Mapped types allow you to redefine existing types, making them dance to your tune.
Why Mapped Types Matter: The Plot Thickens
Mapped types give you the power to streamline your code, enhance its clarity, and catch those sneaky bugs before they wreak havoc. They enable you to write robust, maintainable code that makes your fellow developers nod in gratitude (when used just right).
To illustrate the utility of mapped types we will show how to use a mapped type commonly used in TypeScript already: Pick
.
Let us start with the trusted Person
type example developers overuse ad nauseam to show what it does:
type Person = {
name: string;
age: number;
address: string;
email: string;
};
Sometimes we only want to pass a subset of the Person
type’s properties values to functions. In JavaScript we either pass the entire object value regardless or we pass the properties one-by-one as separate arguments.
In TypeScript we can compute a new type that plucks (or “picks”) a specific subset of properties names to collate in one type definition.
In the example below we pluck (or “pick”) the name
and age
properties from the Person
type defined above.
// Behold, the power of Pick!
type PersonBasicInfo =
Pick<Person, 'name' | 'age'>;
The type computed by Pick<Person, 'name' | 'age'>
looks like this:
{
name: string;
age: number;
}
With the power of Pick
we summoned the PersonBasicInfo
type containing only the name
and age
properties.
Mapped Types Syntax and Basic Plumbing
It’s time to decode the incantations and unravel the key components that make mapped types spellbinding.
The Enchanting Syntax: Casting the Spell
We define mapped types using angle brackets < >
and curly braces { }
. Let’s demystify the basic syntax step-by-step:
type MappedType = {
[Property in ExistingType]: NewProperty
};
MappedType
: This is the name you give to your created mapped type. It can be any valid type name you choose.
[Property in ExistingType]
: This is the magical syntax incantation that sets up your mapped type. Property
represents each property in the existing type that you want to manipulate. ExistingType
is the original type from which you’re deriving the mapped type.
NewProperty
: This is where your creativity comes into play. You define the transformed version of each property in the mapped type.
The Components of Mapped Types: Unmasking the Wizards
Property: The Building Block of Magic
In this example, we’ll transform the properties of an existing type, Person
, into optional properties using mapped types:
type Person = {
name: string;
age: number;
address: string;
};
type OptionalPerson = {
[Property in keyof Person]?: Person[Property]
};
Person
: Our existing type with properties name
, age
, and address
.
OptionalPerson
: The name of our mapped type. We use the keyof
operator to iterate over each property in Person
and create optional properties in OptionalPerson
.
The resulting type looks like the following:
type OptionalPerson = {
name?: string | undefined;
age?: number | undefined;
address?: string | undefined;
email?: string | undefined;
};
Examples of valid constructions of this type include:
// Note: Shakespeare doesn't have an email
const shakespeare: OptionalPerson = {
name: "William Shakespeare",
age: 52,
address: "Stratford-upon-Avon, England",
};
// After dying Shakespeare's address changed
const deathChanges: OptionalPerson = {
address: "Church of the Holy Trinity, Stratford-upon-Avon",
};
const updatedShakespeare: OptionalPerson = {
...shakespeare,
...deathChanges
};
ExistingType: The Source of Power
Let’s explore another example that transforms the property types of an existing type:
type Person = {
name: string;
age: number;
};
type PersonWithOptionalProps = {
[Property in keyof Person]: Person[Property] | undefined
};
Person
: Our existing type with properties name
and age
.
PersonWithOptionalProps
: Our mapped type that retains the same properties as Person
, but with transformed property types. We use the keyof
operator to iterate over each property in Person
and create a union type with undefined
.
You might be wondering what is the difference between OptionalPerson
and PersonWithOptionalProps
? Let’s look at the computed type definition:
type PersonWithOptionalProps = {
name: string | undefined;
age: number | undefined;
address: string | undefined;
email: string | undefined;
};
Note that the property names do not have the ?
suffix. What does this mean in pracitce? Let’s try to set the type to the object literal set to the shakespeare
constant above:
const shakespeare2: PersonWithOptionalProps = {
name: "William Shakespeare",
age: 52,
address: "Stratford-upon-Avon, England",
}; // Errors see message below
Now we get an error! Let’s take a look:
Property 'email' is missing in type
'{ name: string;
age: number;
address: string;
}'
but required in type 'PersonWithOptionalProps'.
So what this is saying is that we do need to set the missing email
property. To model our requirements, we can set it to undefined
to denote that shakespeare2
has no email
property value.
const shakespeare2: PersonWithOptionalProps = {
name: "William Shakespeare",
age: 52,
address: "Stratford-upon-Avon, England",
email: undefined,
};
The above now typechecks and the original error applies to any missing property. We needed to explicitly set the missing properties from Person
to undefined
.
Modifying Properties: Infusing Your Magic
Let’s delve into an example where we omit the modifiers of properties:
type ReadonlyPerson = {
readonly name: string,
readonly age: number,
readonly address: string,
readonly email: string,
};
type MutablePerson = {
-readonly [Property in keyof ReadonlyPerson]: ReadonlyPerson[Property]
};
Person
: Our existing type with properties name
and age
.
MutablePerson
: Our mapped type that mirrors the properties of Person
, but removes the readonly
modifier. We use the -readonly
syntax to update the property modifiers within the mapped type.
MutablePerson
computes to:
type MutablePerson = {
name: string;
age: number;
address: string;
email: string;
};
No readonly
, mom!
Adding Properties
Let’s explore one last example to showcase the versatility of mapped types:
type Circle = {
radius: number;
};
type Cylinder = {
radius: number;
height: number;
};
type CalculateVolume<T> = {
[Property in keyof T]: T[Property];
} & { volume: number };
function calculateCylinderVolume(
cylinder: Cylinder
): CalculateVolume<Cylinder> {
const volume =
Math.PI * cylinder.radius ** 2 * cylinder.height;
return { ...cylinder, volume };
}
Circle
: Our existing type representing a circle with a radius
property.
Cylinder
: Another existing type representing a cylinder with radius
and height
properties.
CalculateVolume<T>
: Our mapped type that extends any existing type T
. It retains the properties of T
and adds a new volume
property of type number
.
calculateCylinderVolume
: A function that takes a Cylinder
object, calculates its volume, and returns a CalculateVolume<Cylinder>
object with the original properties of Cylinder
and the newly added volume
property.
With these examples, you’ve witnessed the magic of mapped types. We have manipulated properties, modified modifiers, transformed property types and added new properties.
A Deeper Dive!
In this section, we explore the mechanics of property transformation and mapped types to reshape our types with a dash of enchantment.
Modifying Property Modifiers: Bending the Rules of Nature
Making Properties Optional: Partial type
Imagine a world where properties have the freedom to choose their destiny, where they can be present or absent at their whim. Enter the wondrous Partial
type! Let’s demystify the derivation of Partial
from first principles:
type Partial<T> = {
[Property in keyof T]?: T[Property]
};
Partial<T>
: computes a new type that mirrors the original type T
, but grants properties the ability to become optional using the ?
modifier. It’s like giving properties a ticket to the land of freedom.
Let’s witness the power of Partial
in action:
interface Wizard {
name: string;
spells: string[];
}
type PartialWizard = Partial<Wizard>;
const wizard: PartialWizard = {}; // Property "name" and "spells" become optional
PartialWizard
: Our transformed type derived from Wizard
using the Partial
mapped type. Now, properties like name
and spells
have the choice to be present or absent, granting flexibility and easing our coding journey.
Making Properties Read-Only: Readonly
type
In the land of code, where properties roam free, some properties prefer to stand tall and unchangeable, like statues of wisdom. Enter the majestic Readonly
type, which bestows the power of immutability upon properties. Let’s unlock the secrets of Readonly
:
type Readonly<T> = {
readonly [Property in keyof T]: T[Property]
};
Readonly<T>
: The alchemical mixture that creates a new type with the same properties as the original T
, but marked as readonly
. It’s like encasing properties in an unbreakable spell, ensuring they remain untouchable.
Behold the might of Readonly
in action:
interface Potion {
name: string;
ingredients: string[];
};
const potion: Potion = {
name: "Elixir of Eternal Youth",
ingredients: ["Unicorn tears", "Moonlight essence"],
};
potion.name = "Forbidden Potion"; // Works
console.debug(potion.name); // prints "Forbidden Potion"
type ReadonlyPotion = Readonly<Potion>;
const ropotion: ReadonlyPotion = {
name: "Elixir of Eternal Youth",
ingredients: ["Unicorn tears", "Moonlight essence"],
};
ropotion.name = "Forbidden Potion"; // Error! Property "name" is read-only
ReadonlyPotion
: Our transformed type created from Potion
using the Readonly
mapped type. Now, properties are guarded against any attempts to change them. This ensures their immutability and preserves their original value.
Excluding Properties: Exclude
type
Sometimes we need to separate the chosen from the unwanted, to exclude certain elements from our type sorcery. Enter the extraordinary Exclude
type, capable of removing specific types from a union. Let’s uncover the essence of Exclude
:
type Exclude<T, U> = T extends U ? never : T;
Exclude<T, U>
: The spell that removes types present in U from the union of types T. Like a forcefield that shields our types from unwanted members.
Let’s witness the might of Exclude
:
type Elements = "Fire" | "Water" | "Air" | "Earth";
type ExcludedElements = Exclude<Elements, "Fire" | "Air">;
const element: ExcludedElements = "Water"; // Success! Excludes "Fire" and "Air"
const forbiddenElement: ExcludedElements = "Fire"; // Error! "Fire" is excluded
ExcludedElements
: Our transformed type, derived from Elements using the Exclude mapped type. With the power of Exclude, we’ve excluded the elements “Fire” and “Air” from our new type, allowing only “Water” and “Earth” to remain.
Transforming Property Types: A Symphony of Type Transformations
Modifying Property Types: Pick and Record types
Picture a symphony where notes dance and melodies intertwine. In the world of TypeScript, we have the harmonious Pick
and Record
types. They pluck or transform property types. Let’s explore their utility:
Pick<T, K>
: computes a new type selecting specific properties K
from the original type T
.
Record<K, T>
: computes a new type by mapping each property key K
to a corresponding property type T
.
Let’s review some examples of Pick
and Record
:
interface Song {
title: string;
artist: string;
duration: number;
}
type SongTitle = Pick<Song, "title">;
type SongDetails = Record<"title" | "artist", string>;
// Only "title" property is allowed
const songTitle: SongTitle = { title: "In the End" };
// Only "title" and "artist" properties are allowed
const songDetails: SongDetails = { title: "Bohemian Rhapsody", artist: "Queen" };
SongTitle
: Our transformed type derived from Song
using the Pick
mapped type. It selects only the title
property, allowing us to focus on the song title.
SongDetails
: Our transformed type derived from the key "title" | "artist"
and the type string
using the Record
mapped type. It maps each property key to the type string
, creating a type that captures the song title
and artist
.
Replacing Property Types: Mapped Types with conditional types
Now, we explore how mapped types can work with conditional types to transform property types:
Conditional Types
: A type-level capability to adapt based on conditions. I wrote about conditional types in a previous post, illustrating good and bad examples.
Let’s observe conditional transformations with code examples:
type Pet = "Cat" | "Dog" | "Bird";
type Treats<T extends Pet> = T extends "Cat" ? string[] : T extends "Dog" ? number : boolean;
const catTreats: Treats<"Cat"> = ["Salmon Treats", "Catnip"]; // Array of strings
const dogTreats: Treats<"Dog"> = 5; // Number
const birdTreats: Treats<"Bird"> = true; // Boolean
Treats<T>
: Our transformed type, where the property type varies based on the condition in the conditional type. The resulting type adapts to the pet’s type from the input, offering an array of strings for a cat, a number for a dog, and a boolean for a bird.
Above we observed the powers of Pick, Record, and the fusion of mapped types with conditional types. Using these TypeScript capabilities, we can reduce boilerplate and express the problem domain more directly.
So, grab your wands, summon your creativity, and let the transformation of properties and property types begin! But be judicuous.
Tips, Tricks, and Best Practices: Unleashing the Magic Safely
Tips for Working with Mapped Types: Navigating the Magical Realm
As we traverse mapped types, it’s essential to keep a few tips and tricks up our sleeves.
-
Consider performance implications:
- Mind the size and complexity of your types.
- Large mappings can lead to longer compile times and increased memory usage.
- Strike a balance between expressiveness and performance for optimal code execution.
-
Beware of limitations and pitfalls:
- Mapped types cannot create new properties that don’t exist in the original type.
- Complex mappings or recursive transformations may result in convoluted and hard-to-read code.
- Stay vigilant and explore alternative strategies when faced with these challenges.
-
Master complex mappings and type inference challenges:
- Embrace utility types like
keyof
and conditional types. - Harness their power to navigate intricate mappings and overcome type inference hurdles.
- Experiment, iterate, and tap into the vast resources of the TypeScript documentation and developer communities.
- Embrace utility types like
With these suggestions, you can leverage TypeScript’s mapped types while avoiding common pitfalls.
Best Practices and Guidelines: Taming the Magic for Large-Scale Projects
To tame the magic of mapped types in large projects, follow these best practices:
- Organize and maintain mapped types:
- Group related types together.
- Create dedicated type files.
- Provide clear documentation.
This fosters maintainability and enables effective use by fellow wizards.
- Ensure type safety and compatibility:
- Use type guards and strict null checks.
- Perform thorough testing.
Validate the safety and compatibility of your mapped types. Integrate comprehensive yet meaningful tests to check their usage is as expected.
- Leverage mapped types for clarity and maintainability:
- Create reusable abstractions.
- Enforce consistent patterns.
- Reduce duplication.
- Avoid reinventing mapped types already provided by TypeScript.
Harness mapped types to enhance code readability and simplify maintenance tasks.
Wrapping it up
Throughout this article we embarked on a wild ride through the enchanted land of mapped types in TypeScript. We’ve seen their transformative abilities, from modifying property modifiers to reshaping property types. We’ve explored tips, tricks, and better practices for mapped types.
Unleash your imagination and continue exploring the enormous possibilities that mapped types offer. Yet avoid reinventing constructs as TypeScript already defines utility types for common uses.
Wield the magic of mapped types with care, always striving for clarity, maintainability, and type safety but do not overuse. Your future teammates will thank you later.
Top comments (0)