Reputation: 803
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
Reputation: 1074158
You have two questions:
How do you check requiresWalks
to see if the animal requires a walk?
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
.
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) {
// ...
}
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);
}
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);
}
Maybe that's better, or maybe it's over-engineered. :-D
Upvotes: 1