TypeScript Guide EP15: Mapped Types and never
Video
Watch the full video on YouTube:
Introduction
In this fifteenth chapter of our TypeScript series we take a review pause to detail two concepts that already appeared in previous videos without being fully explained: mapped types and the never keyword.
Mapped types let us create new types from the keys of another type, using a syntax that resembles creating objects with dynamic attributes in JavaScript. In fact, the utility types Pick and Omit that we already used in earlier episodes are built with mapped types and with never.
What Is a Mapped Type
A mapped type is simply a new type we create from the keys or from a union, following a particular syntax. Let’s go to the code: we create a mapped-types.ts file and define a Copy type that receives a generic called model:
type Copy<Model> = {
[K in keyof Model]: Model[K]
}
Even though it looks like an empty object, [K in keyof Model] tells TypeScript: “for each of the keys of model, create a new attribute”. On the right side we set the data type of that attribute, and to access it we use indexed access type syntax Model[K].
The result is literally a copy:
type PetCopy = Copy<Pet>
// PetCopy has exactly the same attributes as Pet
If we inspect PetCopy in the editor, we’ll see it’s identical to Pet. That’s all it does: it maps each attribute and assigns its corresponding value.
Transforming the Key Format
Things get interesting when we want to change attribute names. For that we use the reserved word as (unlike type assertion, here we’re not telling the compiler “this is of type X”, but rather we interact with the keys on the left side).
Combined with template string syntax we can manipulate the format:
type Copy<Model> = {
[K in keyof Model as `${string & K}`]: Model[K]
}
TypeScript complains if we don’t add string & K because it’s not sure the key is a valid string (it could be number, symbol, etc.). Once that’s fixed, we can prefix any text:
// With an underscore in front
type Copy<Model> = {
[K in keyof Model as `_${string & K}`]: Model[K]
}
// With "copy" in front
type Copy<Model> = {
[K in keyof Model as `copy${string & K}`]: Model[K]
}
The Event Mapper with Capitalize
In the custom types video we had already created an event mapper, but the attributes stayed lowercase. The solution is the Capitalize utility type:
type Events<Model> = {
[K in keyof Model as `on${Capitalize<string & K>}`]: (value: Model[K]) => void
}
Now id, name and owner properly become onId, onName and onOwner.
Transforming the Value Type
We can’t only change keys; we can also change the value format. To type handlers we can give them a function signature:
type Events<Model> = {
[K in keyof Model as `on${Capitalize<string & K>}`]: () => Model[K]
}
Each attribute is now a function that returns its particular data type. If instead we want the function to receive the data and return nothing (a typical mutator case):
type Events<Model> = {
[K in keyof Model as `on${Capitalize<string & K>}`]: (input: Model[K]) => void
}
Whether it returns or not, whether it receives data and returns a boolean, is a business logic decision: here we map everything according to our needs.
Excluding Keys with never
We can also exclude attributes depending on certain conditions. We want to remove any attribute whose value is of numeric type. The recipe: evaluate the value type with extends inside a ternary, and use never to discard the key:
type Copy<Model> = {
[K in keyof Model as Model[K] extends number ? never : K]: Model[K]
}
If the value type extends (conforms to) number, we return never → the key is not mapped. Otherwise we return the key K → the attribute is kept. We’re not excluding values, we’re excluding keys.
type PetCopy = Copy<Pet>
// Now only name and owner remain (id was number)
// Changing number for string:
type PetCopy = Copy<Pet>
// id, owner and years remain (name was string)
Important detail: owner is string? (optional), so string doesn’t capture it because the real type is string | undefined. We have to add it explicitly:
type Copy<Model> = {
[K in keyof Model as Model[K] extends string | undefined ? never : K]: Model[K]
}
The never Keyword
never is a special type that represents something that shouldn’t exist, something that shouldn’t happen. Don’t confuse it with:
| Type | Represents |
|---|---|
any |
Anything (symbol, string, custom class, etc.) |
unknown |
An unknown type (safer than any because it requires narrowing) |
void |
The absence of a return value (value side) |
never |
The total absence of something (type side) |
That’s why when we check that a type conforms to string | undefined we use never: if it’s string or undefined, that key shouldn’t exist in the result, but otherwise we pass it along. It’s resolved with a ternary.
Exhaustiveness with never
never is also present when we check exhaustiveness. We create a union of animals and a switch:
type Animal = "dog" | "cat" | "fish"
declare const animal: Animal
switch (animal) {
case "dog":
console.log("it's a dog")
break
case "cat":
console.log("it's a cat")
break
case "fish":
console.log("it's a fish")
break
}
If we now declare another variable that doesn’t match any case, its type will be never:
const otherAnimal = animal // type: never (in an exhaustive narrowing context)
The same happens in a function with if chaining:
function print(animal: Animal) {
if (animal === "dog") return "dog"
if (animal === "cat") return "cat"
if (animal === "fish") return "fish"
return animal // here the type is never: dog, cat and fish left the equation
}
Once we return dog, TypeScript knows dog is out of the equation; same with cat and fish. In the end nothing remains: never. That’s the presence of never, something that shouldn’t happen.
Modifiers: readonly and Optional
Mapped types support two modifiers that apply to every attribute:
readonly (read-only)
It’s added to the left of the brackets:
type Copy<Model> = {
readonly [K in keyof Model]: Model[K]
}
Every attribute becomes read-only (as if each one were a const): we can no longer do pet.id = 2.
Optional (?)
It’s added to the right:
type Copy<Model> = {
[K in keyof Model]?: Model[K]
}
All attributes become optional. TypeScript even shows a + to indicate it adds the modifier (in Pick/Partial we’d see the opposite).
Removing Modifiers
To remove them we use the minus sign:
// Remove optional from every attribute
type Copy<Model> = {
[K in keyof Model]-?: Model[K]
}
// Remove readonly from every attribute
type Copy<Model> = {
-readonly [K in keyof Model]: Model[K]
}
If Pet had name as readonly, after using -readonly in the mapped type it no longer is. Careful: each modifier applies to every attribute; we can’t exclude readonly from a particular attribute without doing the operation in two parts (extract that element and then add it to the mapped type).
Type Flattening: Getters and Setters
To finish we see an interesting property: mapped types implement a flattening strategy. We create two types that remap every attribute to a getter and a setter:
type WithGetters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type WithSetters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void
}
If we compose both in an intersection:
type ClassLikePet = WithGetters<Pet> & WithSetters<Pet>
The editor doesn’t show us the getter or setter methods in the definition. But if we pass them through Copy (our mapped type):
type ClassLikePet = Copy<WithGetters<Pet> & WithSetters<Pet>>
Now we do see each method one by one. That’s what a mapped type does: it takes every attribute (including methods) and lists them one by one in the new object. That’s why composing mapped types gives us so much power.
What We Can Do with Mapped Types
- Create copies of existing types (
Copy<Model>) - Flatten types (intersections of getters/setters)
- Manipulate key format (prefixes,
Capitalize) - Manipulate value format (function signatures, mutators)
- Conditionally remove attributes with
extends+never - Alter mutability (
readonly/-readonly) - Alter nullability (
?/-?)
Best Practices
- Use
[K in keyof Model]as the starting point for any repetitive type transformation: avoid duplicating structures by hand - Combine
aswith template strings andCapitalizeto generate keys with convention (event handlersonX, gettersgetX, setterssetX) - Use
neverinside a ternary to exclude keys:Model[K] extends Cond ? never : K - Remember
neveris on the type side: it represents total absence, not absence of a return value (that’svoid) - Leverage exhaustiveness: if after a narrowing the type becomes
never, your case coverage is complete - Apply
readonlyand?on the mapped type when you want the modifier on every attribute; for particular attributes, split the operation into two steps
Conclusion
In this episode we reviewed that mapped types create new types from the keys of another type and saw their full power: copying structures, transforming keys with as and template strings, changing value signatures, conditionally excluding attributes with never, and controlling mutability with readonly and ?.
We also understood that never represents something that shouldn’t exist (unlike any, unknown or void) and how to use it both to filter keys in mapped types and to validate exhaustiveness in unions. Composing mapped types (like getters + setters) also lets us flatten intersections and see each method explicitly.
If anything wasn’t clear, leave it in the comments.
Repository
💻 Repository: https://github.com/radagva/ts-example
