Jeremias Nater
Jeremias Nater

Reputation: 803

Right way to manage Arrays with intersecting types and narrow them back down

When using arrays with different Types in typescript I tend to run into issues with properties that are not on all Types.

I ran into the same issue for different types of sections on a page, different roles of users with different properties and so on.

Here is an example with animals:

For example, if you have types Cat, Dog and Wolf:

export type Cat = {
    animal: 'CAT';
    legs: 4;
}
export type Dog = {
    animal: 'DOG',
    legs: 4,
    requiresWalks: true,
    walkDistancePerDayKm: 5
}
export type Wolf = {
    animal: 'WOLF',
    legs: 4,
    requiresWalks: true,
    walkDistancePerDayKm: 20
}
type Animal = Cat | Dog | Wolf;


const animals: Animal[] = getAnimals();
animals.forEach(animal => {
    // here i want to check if the animal requires a walk
    if (animal.requiresWalks) {
        // Property 'requiresWalks' does not exist on type 'Animal'. Property 'requiresWalks' does not exist on type 'Cat'.
        goForAWalkWith(animal)
    }
});
// The type "AnimalThatRequiresWalks" does not exist and i want to know how to implement it
goForAWalkWith(animal: AnimalThatRequiresWalks) {

}

As commented above, property requiresWalks can not be used to narrow down the type.

Also imagine we have 20 animals. I am having difficulties implementing types that may extend animals like "AnimalThatRequiresWalks" which may have multiple properties related to walking animals.

What is a clean implementation to join these types with a type "AnimalThatRequiresWalks" (with properties "requiresWalks true" and "walkDistancePerDayKm") and how can i properly narrow it down to "AnimalThatRequiresWalks"?

Upvotes: 1

Views: 50

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1074158

You have two questions:

  1. How do you check requiresWalks to see if the animal requires a walk?

  2. How do you define the type for animal in goForAWalkWith?

Re #1: You test to see if the object has the property before you try to use it (this is a specific type of narrowing the handbook calls in operator narrowing):

animals.forEach(animal => {
    if ("requiresWalks" in animal && animal.requiresWalks) {
// −−−−−^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        goForAWalkWith(animal)
    }
});

Re #2, you can extract from the Animal union all types that are assignable to {requiresWalks: true} via the Extract utility type:

function goForAWalkWith(animal: Extract<Animal, {requiresWalks: true}>) {
// −−−−−−−−−−−−−−−−−−−−−−−−−−−−−^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    // ...
}

Extract<Animal, {requiresWalks: true}> is Dog | Wolf.

Playground link

You con't have to do that inline if you don't want to, you can define a type alias for it, then use the alias:

type AnimalThatRequiresWalks = Extract<Animal, {requiresWalks: true}>;
// ...
function goForAWalkWith(animal: AnimalThatRequiresWalks) {
    // ...
}

Playground link


In the comments, you've said you'd like to define all of the AnimalThatRequiresWalks properties together in an explicit type for it rather than inferring that type from Dog, Cat, etc. You can do that:

interface AnimalThatRequiresWalks {
    animal: string;
    requiresWalks: true;
    preferredWalkTerrain: "hills" | "paths" | "woods";
    walkDistancePerDayKm: number;
}

export type Cat = {
    animal: "CAT";
    legs: 4;
};
export type Dog = AnimalThatRequiresWalks & {
    animal: "DOG";
    legs: 4;
    walkDistancePerDayKm: 5;        // Only needed if you want to refine its type
    preferredWalkTerrain: "paths";  // Same
};
export type Wolf = AnimalThatRequiresWalks & {
    animal: "WOLF";
    legs: 4;
    walkDistancePerDayKm: 20;       // Only needed if you want to refine its type
    preferredWalkTerrain: "woods";  // Same
}
type Animal = Cat | Dog | Wolf;

declare const animals: Animal[]; // = getAnimals();
animals.forEach(animal => {
    if ("requiresWalks" in animal && animal.requiresWalks) {
        goForAWalkWith(animal)
    }
});

function goForAWalkWith(animal: AnimalThatRequiresWalks) {
    console.log("Go for a walk with the " + animal.animal);
}

Playground link

You might want to experiment with that to see what the developer experience is like. It's really easy to forget to put that AnimalThatRequiresWalks & part on when defining Dog, Wolf, etc., and the type of AnimalThatRequiresWalks["animal"] is string whereas when you infer it is it's the more narrow "Cat" | "Dog" | "Wolf". But it may be more convenient.

It occurs that one way to address the ease of forgetting AnimalThatRequiresWalks & at the beginning is to use a generic type for animals:

interface AnimalThatRequiresWalks {
    animal: string;
    requiresWalks: true;
    preferredWalkTerrain: "hills" | "paths" | "woods";
    walkDistancePerDayKm: number;
}

type AnimalType<R extends boolean, Type extends object> =
    R extends true
    ? AnimalThatRequiresWalks & Type
    : Type;

export type Cat = AnimalType<false, {
    animal: "CAT";
    legs: 4;
}>;
export type Dog = AnimalType<true, {
    animal: "DOG";
    legs: 4;
    walkDistancePerDayKm: 5;        // Only needed if you want to refine its type
    preferredWalkTerrain: "paths";  // Same
}>;
export type Wolf = AnimalType<true, {
    animal: "WOLF";
    legs: 4;
    walkDistancePerDayKm: 20;       // Only needed if you want to refine its type
    preferredWalkTerrain: "woods";  // Same
}>;
type Animal = Cat | Dog | Wolf;

declare const animals: Animal[]; // = getAnimals();
animals.forEach(animal => {
    if ("requiresWalks" in animal && animal.requiresWalks) {
        goForAWalkWith(animal)
    }
});

function goForAWalkWith(animal: AnimalThatRequiresWalks) {
    console.log("Go for a walk with the " + animal.animal);
}

Playground link

Maybe that's better, or maybe it's over-engineered. :-D

Upvotes: 1

Related Questions