Home > Mobile >  Differentiate TypeScript subclasses elegantly
Differentiate TypeScript subclasses elegantly

Time:08-04

I have an application with various kinds of IDs that I pass around as ID objects to enforce type safety. I now want to inherit from the ID base class to differentiate between various kinds of IDs and would like TypeScript to check whether I use the right ID with the right functions and variables. Consider the example below:

class Id {
    id: string

    constructor(id: string) {
        // enforce invariants here or throw

        this.id = id
    }
}

class VideoId extends Id {
    constructor(id: string) {
        super(id)
    }
}

class UserId extends Id {
    constructor(id: string) {
        super(id)
    }
}

function checkUser(userId: UserId) {
    console.log("It's a user!")
}
const videoId = new VideoId("dQw4w9WgXcQ")

// Should be illegal but works!
checkUser(videoId)

The point of the subclasses is to leverage the type checking and prevent calls to checkUser with anything other than a UserId as an argument. However, since TypeScript will allow any structurally identical object as a class instance, the above example compiles.

How do I differentiate the subclasses from the main class with the least boilerplate possible? Ideally, the added code would not appear in the compiled JavaScript and just impact type checking, so I may be looking for something similar to Rust's PhantomData.

CodePudding user response:

Thanks to @jcalz in the comments for the declare idea, I came up with this fairly clean solution with no runtime impact:

class Id<Type extends string> {
    id: string
    declare readonly __type: Type

    constructor(id: string) {
        // enforce invariants here or throw

        this.id = id
    }
}

class VideoId extends Id<'video'> {}

class UserId extends Id<'user'> {}

function checkUser(userId: UserId) {
    console.log("It's a user!")
}
const videoId = new VideoId("dQw4w9WgXcQ")

// Error! Type '"video"' is not assignable to type '"user"'.
checkUser(videoId)

CodePudding user response:

You need to have some distinguishable attribute type between the subclass of Id. The easiest solution I'd go for would be to define a IdType enum, use it on Id parent class as type: IdType field and narrow typing on sub-classes like this:

enum IdType {
  Video,
  User,
}

class Id {
  id: string;
  type: IdType;

  constructor(id: string) {
    // enforce invariants here or throw

    this.id = id;
  }
}

class VideoId extends Id {
  type: IdType.Video = IdType.Video;

  constructor(id: string) {
    super(id);
  }
}

class UserId extends Id {
  type: IdType.User = IdType.User;

  constructor(id: string) {
    super(id);
  }
}

function checkUser(userId: UserId) {
  console.log("It's a user!");
}

const videoId = new VideoId('dQw4w9WgXcQ');
const userId = new UserId('dQw4w9WgXcQ');

// valid
checkUser(userId);
// invalid
checkUser(videoId);

  • Related