Whats the difference between using a class and interface?

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.

Leave a Comment

tech