Home > Mobile >  Typescript class getters that validate property presence
Typescript class getters that validate property presence

Time:06-01

I'm enjoying a new (to me) pattern of creating data models as classes from api responses as they can have reusable derived logic that shouldn't necessarily be returned from the api.

My issue is having multiple getters that I would like to assert existence of a property when referencing each other (or in fact used in other files) but don't, so I'm wondering what I'm missing or what the better solution is:

type TodoResponse = {
    userId: number;
    id: number;
    title: string;
    completed: boolean;
    authors?: string[];
}


class Todo implements TodoResponse {
  userId!: number;
  id!: number;
  title!: string;
  completed!: boolean;
  authors?: string[];

  constructor(todo: TodoResponse) {
    Object.assign(this, todo);
  }

  get hasAuthors(): boolean {
    return Boolean(this.authors?.length);
  }

  get firstAuthor(): string | void {
    if (this.hasAuthors) return this.authors[0]
  // errors with: Object is possibly 'undefined'
  }

  get firstAuthor(): string | void {
    if (this.authors?.length) return this.authors[0]
  // this works but it feels redundant to duplicate logic from other getters
  }
}

CodePudding user response:

What you want is for a check of todo.hasAuthors to act as a type guard which can be used to narrow the type of todo to something that is known to contain a defined authors property. Unfortunately TypeScript does not currently have a way to achieve this.


First, classes cannot be seen to implement discriminated union types; otherwise you could have Todo be assignable to {hasAuthors: true, authors: string[]} | {hasAuthors: false, authors?: undefined}. You could maybe use type assertions to have Todo look like this from the outside:

interface TodoWithAuthors extends TodoResponse {
  hasAuthors: true,
  authors: string[],
  firstAuthor: string
}
interface TodoWithoutAuthors extends TodoResponse {
  hasAuthors: false,
  authors?: undefined,
  firstAuthor: void
}
type Todo = TodoWithAuthors | TodoWithoutAuthors;
const Todo = class Todo implements TodoResponse {
   /* snip, your original impl goes here */
} as new (todo: TodoResponse) => Todo;

const todo = new Todo({
  id: 1, userId: 2, title: "",
  completed: false, authors: Math.random() > 0.99 ? undefined : ["a", "b"]
});
if (todo.hasAuthors) {
  // no compiler errors here
  console.log(todo.authors.join(", ")) // a, b
  console.log(todo.firstAuthor.toUpperCase()) // A
}

But from the inside, the compiler could not see this.hasAuthors as having any effect on this.authors. So this doesn't help you the way you want.


TypeScript does have the concept of user-defined type guard functions and methods, where you can call a boolean-returning function or method, and it will act as a type guard on one of its input arguments (or on the this context of a method). So if hasAuthors were a method instead of a getter, you could do something like this:

class Todo implements TodoResponse {
  userId!: number;
  id!: number;
  title!: string;
  completed!: boolean;
  authors?: string[];

  constructor(todo: TodoResponse) {
    Object.assign(this, todo);
  }

  hasAuthors(): this is { authors: string[], firstAuthor: string } {
    return Boolean(this.authors?.length);
  }

  get firstAuthor(): string | void {
    if (this.hasAuthors()) return this.authors[0] // okay
  }

}

By annotating the return type of hasAuthors() as this is { authors: string[], firstAuthor: string }, I'm saying that a true result of obj.hasAuthors() will narrow the type of obj to something with a defined string[] property (and also a defined firstAuthor property). This works inside the implementation of firstAuthor(). It also works outside the class:

const todo = new Todo({
  id: 1, userId: 2, title: "",
  completed: false, authors: Math.random() > 0.99 ? undefined : ["a", "b"]
});
if (todo.hasAuthors()) {
  // no compiler errors here
  console.log(todo.authors.join(", ")) // a, b
  console.log(todo.firstAuthor.toUpperCase()) // A
}

So, that's great. Unfortunately there is no analogous user-defined type guard property or accessor method functionality. There's an open feature request at microsoft/TypeScript#43368, whose status is currently "Awaiting More Feedback". That means they probably won't even consider implementing such a feature unless they hear about some compelling use cases from the community. If you want to see this happen you might consider going to that issue, giving it a

  • Related