cat1244
cat1244

Reputation: 69

TypeScript describe model

I have the described model.

class Teacher {
  constructor(properties) {
    this.name = properties.name;
    this.category = properties.category;
  }
}
 
class CodingTeacher extends Teacher {
  constructor(properties) {
    super(properties);
    this.programmingLanguage = properties.programmingLanguage;
  }
}
 
class MusicTeacher extends Teacher {
  constructor(properties) {
    super(properties);
    this.instrument = properties.instrument;
  }
}

The API returns a list of Teacher.

getTeachers(): Array<Teacher> {
  return [MusicTeacher, MusicTeacher, CodingTeacher]
}

How to correctly describe the returned data for further work? Is this correct?

getMusicTeachers(): Array<MusicTeacher> {
  this.getTeachers().filter(t => t.category === 'music') as MusicTeacher[];
}

Or when we filter an array from different objects, how should we mark this, since, for example, Teacher does not have the field instrument Is this correct?

this.getTeachers().forEach(teacher => {

  if ((teacher as MusicTeacher).instrument === 'piano') {
    return;
  }

  if ((teacher as CodingTeacher).programmingLanguage === 'JS') {
    return;
  }

  return teacher;
}

Not quite sure if I'm working correctly with a list of different objects.

Upvotes: 0

Views: 135

Answers (2)

Linda Paiste
Linda Paiste

Reputation: 42228

What you are dealing with here is called a "discriminated union". If category is 'music' than the object has a property instrument. And if the category is 'coding' then the object has property programmingLanguage.

interface Teacher {
    name: string;
    category: string;
}

interface CodingTeacher extends Teacher {
    category: 'coding';
    programmingLanguage: string;
}

interface MusicTeacher extends Teacher {
    category: 'music';
    instrument: string;
}

type ApiTeacher = CodingTeacher | MusicTeacher

If we define a teacher variable as the union of two types, then typescript is able to refine the type based on checking the category property. But there are some limitations.

This sort of if branching works just fine:

const teacher: ApiTeacher = {/*...*/}
if (teacher.category === "music") {
  return teacher.instrument;  // teacher is MusicTeacher
} else {
  return teacher.programmingLanguage; // teacher is CodingTeacher
}

But this is an error because the type refining doesn't work on our array callback.

getMusicTeachers(): Array<MusicTeacher> {
   return this.getTeachers().filter(t => t.category === 'music');
}

We can create a user-defined type guard to check whether a Teacher object is actually a CodingTeacher and another to check for MusicTeacher. These will refine the types inside of a .filter callback due to the is assertion in the type guard's return.

const isMusicTeacher = (teacher: Teacher): teacher is MusicTeacher => {
    return teacher.category === "music";
}

const isCodingTeacher = (teacher: Teacher): teacher is CodingTeacher => {
    return teacher.category === "coding";
}

So now there is no error here:

getMusicTeachers(): Array<MusicTeacher> {
  return this.getTeachers().filter(isMusicTeacher);
}

In order to access a property on an object which can be a union of different types that property must be defined on every object. So CodingTeacher | MusicTeacher won't allow us to access teacher.instrument.

One option is to check that the teacher is a MusicTeacher first.

if (isMusicTeacher(teacher) && teacher.instrument === 'piano') {
  return;
}

Another option is to tweak the definition of ApiTeacher such that each teacher includes the other teacher's properties as optional properties. It becomes a lot easier to map objects in the array from the API if we say that they maybe have a programmingLanguage and maybe have an instrument.

type ApiTeacher = (CodingTeacher | MusicTeacher) & { 
    programmingLanguage?: string;
    instrument?: string;
}

Now this is fine:

this.getTeachers().forEach(teacher => {
  if (teacher.instrument === 'piano') {
    return;
  }
  if (teacher.programmingLanguage === 'JS') {
    return;
  }
}

Typescript Playground Link

Upvotes: 2

T.J. Crowder
T.J. Crowder

Reputation: 1074999

You can use in to avoid the type assertions that may be incorrect. More on that in a moment.

But beware that there's a difference between interfaces and classes. An interface describes an object with a given set of properties with specific types. A class defines an interface and an implementation. Unless your API returns objects created via those constructors (or similar), the objects you get from it won't be members of those classes, just objects matching the interface those classes describe.

If that's fine for what you're doing, use interface rather than class and yes, a type assertion to assert that a Teacher is actually specifically a MusicTeacher once you've established that's true is fine. You might consider having a reusable type predicate function¹ for each interface, e.g.:

function isMusicTeacher(t: Teacher): t is MusicTeacher {
    return "category" in t;
}

Then getting MusicTeacher[] from Teacher[] is:

getMusicTeachers() { // You don't need the return type `MusicTeacher[]`, TypeScript will infer it
  return this.getTeachers().filter(isMusicTeacher);
}

But if you rely on the implementations those classes provide, a type assertion isn't adequate, you need to call the constructors and initialize the resulting objects with the information from the API.


¹ That page claims it's deprecated and links to a new page, but the new page doesn't describe type predicates (yet?). The documentation is in flux as of this writing.

Upvotes: 0

Related Questions