Home > Software engineering >  Abstract type classes/interfaces in typescript
Abstract type classes/interfaces in typescript

Time:04-22

Is it possible to have superclasses/interfaces and subclasses/implementations that depict the typing in an abstract way?

Lets say you have types and following functions:

class Animal {
  name: string;
}
class Bird extends Animal {
  canFly: boolean
}
class Dog extends Animal {
  tailLength: number;
}

enum animals {
  bird = 'bird',
  dog = 'dog'
}

class AnimalDTO {
  type: animals;
  information: Animal;
}

function registerAnimal(animalDto: AnimalDTO) {
  if (animalDto.type == animals.dog) {
    petDog(animalDto.information);
  }
}

function petDog(dog: Dog) {
  console.log(`Petting ${dog.name} with tail length ${dog.tailLength}`)
}

Now in the registerAnimal function it complains

Argument of type 'Animal' is not assignable to parameter of type 'Dog'.
  Property 'tailLength' is missing in type 'Animal' but required in type 'Dog'.

How can I have a function that accepts this global DTO and I can apply logic to, based on the passed type, project the passed information to the specific subclass?

CodePudding user response:

The problem is that you're trying to narrow the type of animalDto.information based on animalDto.type, which will only work if animalDto is a union of DTOs for specific animals (more on that below).

The usual way to do this is to not have an Animal base class, and instead have a union type of specific animal types (this is called a discriminated union — a union where you can tell the members apart by some characteristic [name in this case]):

interface Bird {
    name: "bird";
    canFly: boolean;
}

interface Dog {
    name: "dog";
    tailLength: number;
}

type Animal = Bird | Dog;

(I've used interfaces there, but you could do it with Bird and Dog classes as well.)

That way, we can differentiate animals based on the type of their name (which is a string literal type, "bird" or "dog", not just a string):

interface AnimalDTO {
    information: Animal;
}

function registerAnimal(animalDto: AnimalDTO) {
    const animal = animalDto.information;
    if (animal.name == "dog") {
        petDog(animal);
    }
}

function petDog(dog: Dog) {
    console.log(`Petting ${dog.name} with tail length ${dog.tailLength}`);
}

Playground link

If AnimalDTO has to have a separate type, you can do the same thing with it that I did with Bird and Dog above:

interface BirdDTO {
    type: "bird";
    information: Extract<Animal, {name: "bird"}>;
}

interface DogDTO {
    type: "dog";
    information: Extract<Animal, {name: "dog"}>;
}

type AnimalDTO = BirdDTO | DogDTO;

function registerAnimal(animalDto: AnimalDTO) {
    if (animalDto.type == "dog") {
        petDog(animalDto.information);
    }
}

The Extract bit identifies the specific name with a name matching the type we're using for the DTO.

Playground link

If there are a lot of animals, repeating "bird" and "dog" in both places is error-prone, so we can use a generic type to create them:

type MakeAnimalDTO<AnimalType extends string> = {
    type: AnimalType;
    information: Extract<Animal, {name: AnimalType}>;
}

type BirdDTO = MakeAnimalDTO<"bird">;

type DogDTO = MakeAnimalDTO<"dog">;

type AnimalDTO = BirdDTO | DogDTO;

Playground link

There are lots of ways to do this, but the fundamental thing is to have a type-based way of differentiating the types from one another; in the above that's the "bird" and "dog" string literal types.

CodePudding user response:

It is kind of awkward to tell TypeScript that there is a relationship between the type and information properties. But you could define all possible relations in a separate type:

class AnimalDTO {
    type!: animals
    information!: Animal
}

type AnimalDTOtype = {
    type: animals.bird,
    information: Bird
} | {
    type: animals.dog
    information: Dog
}

function registerAnimal<T extends keyof typeof animals>(animalDto: AnimalDTOtype) {
  if (animalDto.type == animals.dog) {
    petDog(animalDto.information);
  }
}

CodePudding user response:

You can do:

class Animal {
  name!: string;
}

class Bird extends Animal {
  canFly!: boolean;
}

class Dog extends Animal {
  tailLength!: number;
}

enum EAnimalType {
  bird = 'bird',
  dog = 'dog',
}

class AnimalDTO {
  type!: EAnimalType;

  information!: Animal;

  tailLength?: number;
}

function petDog(dog: Dog) {
  console.log(`Petting ${dog.name} with tail length ${dog.tailLength}`);
}

function registerAnimal(animalDto: AnimalDTO) {
  if (animalDto.type == EAnimalType.dog) {
    const dog = new Dog();
    dog.name = animalDto.information.name;
    dog.tailLength = animalDto.tailLength || 0;
    petDog(dog);
  }
}

registerAnimal({
  type: EAnimalType.dog,
  information: {
    name: 'Yaki',
  },
  tailLength: 3,
});

Check the working code at: www.typescriptlang.org/play

  • Related