Greetings, esteemed coding enthusiasts! Today, we embark on a journey into the realm of TypeScript mapped types. Brace yourselves for an insightful exploration enriched with practical insights and applications.
So, what exactly are TypeScript mapped types? Picture having the ability to effortlessly modify and refine existing types with a simple keystroke. Mapped types empower you to enhance your type definitions by seamlessly adding, removing, or modifying properties as needed.
Let's delve into some practical examples featuring our cherished furry companions, Dogs and Cats!
abstract class Animal {
abstract name(): string
}
class Dog extends Animal {
override name(): string {
return 'Lassie'
}
}
class Cat extends Animal {
override name(): string {
return 'Garfield'
}
}
We have our reliable entities, Dog and Cat, demonstrating their abilities by extending the Animal class.
Now, let's exemplify mapped types by deriving the sound type emitted by our Dog and Cat.
type SoundOf<T> = T extends Dog ? 'Bark' : 'Meow'
Explore this innovative feature we've developedโa specialized sound-selector for animals. Customized for each critter, let's test its functionality and unveil the distinct melodies our furry companions produce.
A dog goes "bark," right? Let's see if TypeScript agrees!
type DogSound = SoundOf<typeof dog> // Inferred as 'Bark'
And surely, a cat goes "meow." Let's check it out!
type CatSound = SoundOf<typeof cat> // Inferred as 'Bark'?!?!
What's occurring here is beyond comprehension! At this juncture, dear reader, we felt like we were losing grasp of reality. However, sanity is merely a debugging session away! ๐ง ๐ป๐
What's going on here? ๐ค Picture this: TypeScript views types as shapes of objects. So, when we compare Cat and Dog types, TypeScript sees them both as objects with a solitary function (name()). In other words, it asks, "Does this Cat object with a single name() function match the shape of this Dog object with a single name() function?" And voilร , it returns 'Bark' for a Cat! What a mix-up!
Here's the scoop on using mapped types to avoid this peculiar mix-up. Think of it as playing detective โ you must differentiate between our feline friends and canine companions. But fret not, it's simpler than it appears! Just introduce a unique trait to the Dog class, akin to assigning them distinct badges. As long as one possesses a badge the other lacks, TypeScript will discern the disparity. Let's give it a try, shall we? ๐ฑ๐ถ
class Dog extends Animal {
chewsShoes = true // Marking our territory!
override name(): string {
return 'Lassie'
}
}
What does Typescript think?
type DogSound = SoundOf<typeof dog>; // Inferred as 'Bark'
type CatSound = SoundOf<typeof cat>; // Inferred as 'Meow'
And just like magic, TypeScript gets it right!
Tread carefully though! The addition of random fields could result in chaos. Consider this scenario: if both Cat and Dog were to sport a 'chewShoes' field, we'd find ourselves right back to square one!
But fear not, courageous coder, for there exists a ray of hope: Symbols! These provide a means to hide a field away from everyone else. It's akin to bestowing upon Dog its very own secret handshake, inaccessible to Cat.
A symbol is essentially a UUID which we can to create a unique variable name for our Dog class:
const dogMarker: unique symbol = Symbol('Dog')
class Dog extends Animal {
readonly [dogMarker]: undefined = undefined // Unleashing the power of symbols!
override name(): string {
return 'Lassie'
}
}
Voilร ! By transforming the 'chewsShoes' field name into a symbol, we cloak it in mystery, hidden from any Cat implementations. Problem solved, just like that! (As long as we keep that dogMarker
variable private!)
Another twist in the tail (pun intended)! When we thought we had our solution tucked neatly away, along comes the issue of visibility during enumeration and in the toString()
output. Not ideal, especially when we're aiming to keep this implementation detail under wraps.
Object.keys(new Dog()) // ['<a-uuid-value-here>']
But fear not, for we've got a nifty trick up our sleeve! Let's disable enumeration for this field, ensuring it remains hidden from prying eyes. And why stop there? Let's go the extra mile and make it readonly and un-deletable too, just to really drive the point home!
With these safeguards in place, our field remains concealed to all except those privy to its secrets.
class Dog extends Animal {
readonly [dogMarker]: undefined = undefined;
constructor() {
super()
Object.defineProperty(this, dogMarker, { enumerable: false, writable: false, configurable: false})
}
override name(): string {
return 'Lassie'
}
}
But wait, there's more! What about generics, you ask? Most applications deal with more than Dogs and Cats!
Let's construct a marker interface mirroring the structure of the Dog class, encapsulated within an interface to facilitate reusability.
const marker: unique symbol = Symbol('my unique string here')
interface Marker {
readonly [marker]: undefined
}
We're taking a leaf out of our Dog class's playbook and applying the same strategy to an interface. Just like how our Dog class boasts a 'dogMarker' field to distinguish itself, our interface will wield a similar marker to assert its uniqueness.
By mandating our generic type to implement the 'Marker' interface, we're forcing our types to implement a unique marker to identify them. With generics, we ensure whatever type is passed to SoundOf
must implement our Marker
interface. The Marker
interface must have the unique marker
field to distinguish it from other types, allowing Typescript to tell types apart. ๐
type SoundOf<T> = T extends Marker ? 'Bark' : 'Meow';
Thanks to our 'SoundOf' type using generics with our Marker interface, we're not confined solely to Dogs and Cats; we can accommodate any type seamlessly.
Let's expand our repertoire with some dog breeds, all of which are destined to 'Bark'! ๐พ๐
We'll implement the Marker interface to enforce this behaviour.
class Labrador extends Animal implements Marker {
readonly [marker]: undefined = undefined
// ...
}
class Poodle extends Animal implements Marker {
readonly [marker]: undefined = undefined
// ...
}
// etc...
And just to be fair, let's introduce some more breeds of Cats too.
class Tabby extends Animal {
// ...
}
class Ragdoll extends Animal {
// ...
}
// etc...
And drawing it all together, we get:
type LabradorSound = SoundOf<typeof dog>; // Inferred as 'Bark'
type PoodleSound = SoundOf<typeof dog>; // Inferred as 'Bark'
type TabbySound = SoundOf<typeof cat>; // Inferred as 'Meow'
type RagdollSound = SoundOf<typeof cat>; // Inferred as 'Meow'
I'm sure you're thinking: "Great, but didn't we just discern the types by adding a field again?". While it may seem like we've circled back to distinguishing types through fields, the key difference lies in consistency and abstraction.
By crafting an interface to define how types are distinguished, we've abstracted away the concrete implementation details. This means our mapped type doesn't need to know the specifics of each Cat or Dog. It can work its magic on any type that adheres to our marker interface, making our solution incredibly flexible and versatile.
Moreover, with classes capable of implementing multiple interfaces, the possibilities are endless! We can create a whole arsenal of marker interfaces tailored to different mapped-type scenarios, all without burdening our types with unnecessary knowledge of concrete implementations! ๐ ๏ธ๐ฒ๐
And there you have it, folks! TypeScript mapped types demystified and conquered.