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