Is there a way of providing polymorphic type discrimination similar to Rust's enums?

Viewed 84

I've recently returned to JS/TS after coding a lot in Rust and this is a feature I really miss.

So suppose that I have a value which may be an instance of Cat or an instance of Dog and depending on which class it is from, I want to perform different logic. Since Javascript cannot do classname == (I think?), what I usually rely on is also passing a string indicating the class like this:

function groomAnimal(animal: Cat | Dog, animalType: 'cat' | 'dog') {
    if (animalType === 'cat') {
        (animal as Cat).trimNails();
    } else {
        (animal as Dog).trimFur();
    }
}

I hate this because it's unwieldy and the compiler cannot guarantee that the caller correctly fulfills the contract. On the other hand, in Rust this would be quite elegant:

enum Animal {
    Cat(CatData),
    Dog(DogData),
}

fn groomAnimal(animal: Animal) {
    match animal {
        Cat(catData) => catData.trimNails(),
        Dog(dogData) => dogData.trimFur(),
    }
}

Basically, the enum itself which indicates the "class", also holds the "class" data and thus obtaining the data and checking the data type are the same operation

Is there any pattern in typescript that allows for polymorphic function signatures while ensuring at compile time that the code operates on the correct data types?

2 Answers

Keeping a separate value indicating the type is very error prone. If Cat and Dog are class types (not just interfaces), you can use instanceof to verify its type directly:

class Cat {
    trimNails() {}
}

class Dog {
    trimFur() {}
}

function groomAnimal(animal: Cat | Dog) {
    if (animal instanceof Cat) {
        // animal must be Cat, so you can call trimNails()
        animal.trimNails();
    } else {
        // animal must otherwise be a Dog, so you can call trimFur()
        animal.trimFur();
    }
}

You could create a composable structure with Cat/Dog and some identifier attribute, bit similar to your approach but differently.

Something like this,

type Animal = Cat & { animalType: 'Cat' } | Dog & { animalType: 'Dog' }

You groomAnimal method would now take the composed structure,

function groomAnimal(animal: Animal) {
    if (animal.animalType === 'Cat') {
        animal.trimNails();
    } else {
       animal.trimFur();
    }
}

Here is one small sample where I create a cat and pass it to the groomAnimal method,

const tom = {
    trimNails() {
        console.log('Trimming nails')
    }
}

groomAnimal({ ...tom, kind: 'Cat'})

In you case, you said you parse these from some api calls. You could already prepare your Animal/Animals[] object when parsing and use that later when grooming.

And, of course, the compiler can guarantee that the caller actually correctly fulfils the contract as you can see from the code.

Related