Video

Watch the full video on YouTube:

Mapped types and never in TypeScript

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

  1. Create copies of existing types (Copy<Model>)
  2. Flatten types (intersections of getters/setters)
  3. Manipulate key format (prefixes, Capitalize)
  4. Manipulate value format (function signatures, mutators)
  5. Conditionally remove attributes with extends + never
  6. Alter mutability (readonly / -readonly)
  7. Alter nullability (? / -?)

Best Practices

  1. Use [K in keyof Model] as the starting point for any repetitive type transformation: avoid duplicating structures by hand
  2. Combine as with template strings and Capitalize to generate keys with convention (event handlers onX, getters getX, setters setX)
  3. Use never inside a ternary to exclude keys: Model[K] extends Cond ? never : K
  4. Remember never is on the type side: it represents total absence, not absence of a return value (that’s void)
  5. Leverage exhaustiveness: if after a narrowing the type becomes never, your case coverage is complete
  6. Apply readonly and ? 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

TypeScript Guide EP14: The Satisfies OperatorES6: 44 Features You Should Know