What is the difference between doing this
export class Comment {
likes: string;
comment: string;
constructor(likes: string, comment: string){
this.comment = comment;
this.likes = likes;
}
}
and this
export interface CommentInterface {
likes: string;
comment: string;
}
in relation to declaring an observable type
register: Observable<CommentInterface[]> {
return this.http.get()
}
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.
Note that throughout this answer I do not use a type for Comment.likes
because in the question you have it as a string
, but it feels like a number
to me, so I leave it up the reader.
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() {
const [topComment] = this.comments.slice().sort((x, y) => y < x);
// since there might not be any comments, it is likely that a check will be made here
if (topComment instanceof Comment) { // always false at runtime
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;
}
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, 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 (Number(likes) < 0 || !Number.isSafeInteger(Number(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.
As in most other OOP languages: For classes you can create instances (via their constructor), while you cannot create instances of interfaces.
In other words: If you just return deserialized JSON, then it makes sense to use the interface to avoid confusion. Lets assume you add some method foo
to your Comment
class. If your register
method is declared to return a Comment
then you might assume that you can call foo
on the return value of register. But this wont work since what register
effectively returns is just deserialzed JSON without your implementation of foo
on the Comment
class. More specifically, it is NOT an instance of your Comment
class. Of course, you could also accidentally declare the foo
method in your CommentInterface
and it still wouldn't work, but then there would be no actual code for the foo
method that is just not being executed, making it easier to reason about the root cause of your call to foo
not working.
Additionally think about it on a semantic level: Declaring to return an interface gurantees that everything that is declared on the interface is present on the returned value. Declaring to return a class instance gurantees that you... well... return a class instance, which is not what you are doing, since you are returning deserialized Json.