As JB Nizet quite correctly points out, the deserialized JSON values that result from HTTP requests will never be instances of a class.
While the dual role (see below) of the class
construct in TypeScript makes it possible to use a class
to describe the shape of these response values, it is a poor practice because the response text will be deserialized into plain JavaScript objects.
Class declarations in JavaScript and TypeScript:
In JavaScript, a class declaration
class Comment {
constructor(likes, comment) {
this.likes = likes;
this.comment = comment;
}
}
creates a value that can be instantiated using new
to act as what is essentially a factory.
In TypeScript, a class declaration creates two things.
The first is the exact same JavaScript class value described above.
The second is a type that describes the structure of the instances created by writing
new Comment(4, 'I love your essays')
That second artifact, the type, can then be used as a type annotation such as in your example of
register(): Observable<Comment[]> {
return this.http.get()
}
which says that register
returns an Observable
of Arrays of Comment
class
instances.
Now imagine your HTTP request returns the following JSON
[
{
"likes": 4,
"comment": "I love you oh so very much"
},
{
"likes": 1,
"comment": "I lust after that feeling of approval that only likes can bring"
}
]
However the method declaration
register(): Observable<Comment[]>;
while it correctly allows callers to write
register().subscribe(comments => {
for (const comment of comment) {
if (comment.likes > 0) {
likedComments.push(comment);
}
}
});
which is all well and good, it unfortunately also allows callers to write code like
getComments() {
register().subscribe(comments => {
this.comments = comments;
});
}
getTopComment() {
// since there might not be any comments
// it is likely that a check will be made here
const [topComment] = this.comments.slice().sort((x, y) => y - x);
// BUG! Always false at runtime.
if (topComment instanceof Comment) {
return topComment;
}
}
Since comments are not actually instances of the Comment
class the above check will always fail and hence there is a bug in the code. However, TypeScript will not catch the error because we said that comments
is an array of instances of the Comment
class and that would make the check valid (recall that the response.json()
returns any
which can be converted to any type without warnings so everything appears fine at compile time).
If, however we had declared comment as an interface
interface Comment {
comment: string;
likes: number;
}
then getComments
will continue to type check, because it is in fact correct code, but getTopComment
will raise an error at compile time in the if statement because, as noted by many others, an interface
, being a compile time only construct, can not be used as if it were a constructor to perform an instanceof
check. The compiler will tell us we have an error.
Remarks:
In addition to all the other reasons given, in my opinion, when you have something that represents plain old data in JavaScript/TypeScript, using a class is usually overkill. It creates a function with a prototype and has a bunch of other aspects that we do not likely need or care about.
It also throws away benefits that you get by default if you use objects. These benefits include syntactic sugar for creating and copying objects and TypeScript's inference of the types of these objects.
Consider
import Comment from 'app/comment';
export default class CommentService {
async getComments(): Promse<Array<Comment>> {
const response = await fetch('api/comments', {httpMethod: 'GET'});
const comments = await response.json();
return comments as Comment[]; // just being explicit.
}
async createComment(comment: Comment): Promise<Comment> {
const response = await fetch('api/comments', {
httpMethod: 'POST',
body: JSON.stringify(comment)
});
const result = await response.json();
return result as Comment; // just being explicit.
}
}
If Comment
is an interface and I want to use the above service to create a comment, I can do it as follows
import CommentService from 'app/comment-service';
export async function createComment(likes: number, comment: string) {
const commentService = new CommentService();
await commentService.createCommnet({comment, likes});
}
If Comment
were a class, I would need to introduce some boiler plate by necessitating the import
of Comment
. Naturally, this also increases coupling.
import CommentService from 'app/comment-service';
import Comment from 'app/comment';
export async function createComment(likes, comment: string) {
const commentService = new CommentService();
const comment = new Comment(likes, comment); // better get the order right
await commentService.createCommnet(comment);
}
That is two extra lines, and one involves depending on another module just to create an object.
Now if Comment
is an interface, but I want a sophisticated class that does validation and whatnot before I give it to my service, I can still have that as well.
import CommentService from 'app/comment-service';
import Comment from 'app/comment';
// implements is optional and typescript will verify that this class implements Comment
// by looking at the definition of the service method so I could remove it and
// also remove the import statement if I wish
class ValidatedComment implements Comment {
constructor(public likes, public comment: string) {
if (likes < 0 || !Number.isSafeInteger(likes)) {
throw RangeError('Likes must be a valid number >= 0'
}
}
}
export async function createComment(likes, comment: string) {
const commentService = new CommentService();
const comment = new ValidatedComment(likes, comment); // better get the order right
await commentService.createCommnet(comment);
}
In short there are many reasons to use an interface
to describe the type of the responses and also the requests that interact with an HTTP service when using TypeScript.
Note: you can also use a type
declaration, which is equally safe and robust but it is less idiomatic and the tooling around interface
often makes it preferable for this scenario.