DEV Community

Cover image for Defining static methods in interfaces with TypeScript
Lucas Santos
Lucas Santos

Posted on

Defining static methods in interfaces with TypeScript

Yesterday I was organizing the TS Formation classes, and I came across something quite interesting that I, myself, have struggled with for years (including yesterday).

When we talk about object-oriented programming, one of the most difficult things to understand is the concept of static properties versus instance properties, and this is especially difficult when we try to type a dynamic language on top of static typing.

I won't go into details about what static or non-static methods are in this article because there are many other contents on the Internet that you can consume that will be much more detailed than I can be here.

But it's worth it refreshing your memory.

Static methods

Static methods, or static attributes, are attributes that exist in any instance of a class, they are defined at the constructor level, that is, the class itself has these methods and therefore all instances of these classes will also have them.

A common static method is when we are creating a domain object, or a database entity, for example:

class Person {
  static fromObject (obj: Record<string, unknown>) {
    const instance = new Person()
    instance.prop = obj.prop
    return instance
  }

  toObject () {
    return {
     prop: this.prop
    }
}
Enter fullscreen mode Exit fullscreen mode

The fromObject method exists in all classes, it is above any instance and therefore cannot use the this keyword because this has not yet been initialized, and because you are in a context above any instance that this could refer to.

In this case, we are receiving an object and creating a new instance of the class directly with it. To execute this code, instead of doing something standard like:

const p = new Person()
p.fromObject(etc) // error, the property does not exist in the instance
Enter fullscreen mode Exit fullscreen mode

We need to call the method directly from the class constructor:

const p = Person.fromObject(etc)
Enter fullscreen mode Exit fullscreen mode

The problem

Static methods are very common in strongly typed languages because you have a clear separation between the static moment of the class and the "dynamic" moment.

But what happens when we need to type a dynamic language with static typing?

In TypeScript we will have some errors when we try to declare, for example, that a class has dynamic and static methods and try to describe both in an interface:

interface Serializable {
  fromObject (obj: Record<string, unknown>): Person
  toObject (): Record<string, unknown>
}

class Person implements Serializable
// Class 'Person' incorrectly implements interface 'Serializable'.
// Property 'fromObject' is missing in type 'Person' but required in type
// 'Serializable'.
Enter fullscreen mode Exit fullscreen mode

This happens because interfaces in TypeScript act on the "dynamic side" of the class, so it is as if all interfaces were instances of the class in question, but not the class itself.

Fortunately, TypeScript has a way to declare a class as a constructor, the so-called Constructor Signatures:

interface Serializable {
  new (...args: any[]): any
  fromObject(obj: Record<string, unknown>): Person
  toObject(): Record<string, unknown>
}
Enter fullscreen mode Exit fullscreen mode

It should work now, right? Unfortunately not, because even if you implement the method manually, the class will still say that you have not implemented the fromObject method.

The problem of static reflection

And the problem goes further, for example, if we wanted to make a database class that uses the entity name straight from the class to create a file, this is done through the name property in any class, this is a static property that exists in all instantiable objects:

interface Serializable {
  toObject(): any
}

class DB {
  constructor(entity: Serializable) {
    const path = entity.name // name does not exist in the property
  }
}
Enter fullscreen mode Exit fullscreen mode

Okay, so we can replace entity.name for entity.constructor.name, which works, but what about when we need to create a new entity from an object?

interface Serializable {
  toObject(): any
}

class DB {
  #entity: Serializable
  constructor(entity: Serializable) {
    const path = entity.constructor.name
    this.#entity = entity
  }

  readFromFile() {
    // we read from this file here
    const object = 'file content as an object'
    return this.#entity.fromObject(object) // fromObject does not exist
  }
}
Enter fullscreen mode Exit fullscreen mode

So, we have a choice: either we prioritize the instance, or we prioritize the constructor...

The solution

Fortunately, we have a solution to this problem. It is not very pretty, but there are some ideas in the TypeScript repository (like this one and this one) that have been analyzing the possibility of adding static definitions to interfaces since 2017.

However, since this idea hasn't arrived yet, what we have is the definition of two parts of the interface, the static part and the instance part:

export interface SerializableStatic {
  new (...args: any[]): any
  fromObject(data: Record<string, unknown>): InstanceType<this>
}

export interface Serializable {
  id: string
  toJSON(): string
}
Enter fullscreen mode Exit fullscreen mode

It is important to note that the constructor in new(...args: any[]): any has to be typed as any in the return, otherwise it becomes a circular reference

With these two parts of the class typed, we can say that the class only implements the instance part:

class Person implements Serializable {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now, we can say that our database will receive two types of parameters, one will be the static part, which we will call S
and the other one will be the dynamic (or instance) part, which we will call I, and S will always extend SerializableStatic
and I will always extend Serializable and, by default, will be the type of an instance of S , which can be defined by the InstanceType<S> type utility:

class Database<S extends SerializableStatic, I extends Serializable = InstanceType<S>> {}
Enter fullscreen mode Exit fullscreen mode

Now we can have our properties normally, for example:

class Database<S extends SerializableStatic, I extends Serializable = InstanceType<S>> {
  #dbPath: string
  #data: Map<string, I> = new Map()
  #entity: S

  constructor(entity: S) {
    this.#dbPath = resolve(dirname(import.meta.url), `.data/${entity.name.toLowerCase()}.json`)
    this.#entity = entity
    this.#initialize()
  }
}
Enter fullscreen mode Exit fullscreen mode

And in our #initialize method we will use the fromObject method to be able to read directly from the file and turn it into an instance of a class:

class Database<S extends SerializableStatic, I extends Serializable = InstanceType<S>> {
  #dbPath: string
  #data: Map<string, I> = new Map()
  #entity: S

  constructor(entity: S) {
    this.#dbPath = resolve(dirname(import.meta.url), `.data/${entity.name.toLowerCase()}.json`)
    this.#entity = entity
    this.#initialize()
  }

  #initialize() {
    if (existsSync(this.#dbPath)) {
      const data: [string, Record<string, unknown>][] = JSON.parse(readFileSync(this.#dbPath, 'utf-8'))
      for (const [key, value] of data) {
        this.#data.set(key, this.#entity.fromObject(value))
      }
      return
    }
    this.#updateFile()
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, we can have methods like get and getAll, or even save that receive and return instances only.

get(id: string): I | undefined {
  return this.#data.get(id)
}

getAll(): I[] {
  return [...this.#data.values()]
}

save(entity: I): this {
  this.#data.set(entity.id, entity)
  return this.#updateFile()
}
Enter fullscreen mode Exit fullscreen mode

Now, when we use a database of this type like:

class Person implements Serializable {
  // enter code here
}

const db = new DB(Person)
const all = db.getAll() // Person[]
const oneOrNone = db.get(1) // Person | undefined
db.save(new Person()) // DB<Person>
Enter fullscreen mode Exit fullscreen mode

If you enjoyed this content, come and learn more about TypeScript with me in TS Formation! Just go to https://formacaots.com.br and join over 250 students who are already enjoying it!

Top comments (3)

Collapse
 
artxe2 profile image
Yeom suyun

Wouldn't it be better to write it like this instead of using any?

interface IFConstructor {
  new (): IF
  func1(): void
}
interface IF {
  func2(): void
}

class Obj implements IF {
  static func1() {

  }
  func2() {

  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
_staticvoid profile image
Lucas Santos

Also possible, in general it doesn't make a lot of difference because we won't be using the instance constructor typing in general.

Collapse
 
daniel_dotsenko_8bd5df2a4 profile image
Daniel Dotsenko

Did not want to pollute data class with static methods when data class constructor is already effectively a static interface. Also tightened the sig for constructor to take explicitly just a single object with named attrs. Sprinkled static interface with <T> to allow it to claim what type new (): T returns:

export interface Serializable {
    id: string
}

export interface SerializableStatic<T> {
    new (data: Record<string, any>): T
}


export interface CollectionBase<T> {
    add: (o: T) => void
    get: (id: string) => T | null
}

export class LocalStoreCollection<Tstat extends SerializableStatic<T>, T extends Serializable = InstanceType<Tstat>> implements CollectionBase<T>{

    namespace: string
    instantiator: Tstat

    constructor(instantiator: Tstat, namespace: string) {
        this.namespace = namespace + '.'
        this.instantiator = instantiator
    }

    add(o: T) {
        const id = (o as Serializable).id
        const key = this.namespace + id
        const serialized = JSON.stringify(o)
        localStorage.setItem(key, serialized)
    }

    _deserialize(dataStr: string): T {
        const data: Record<string, unknown> = JSON.parse(dataStr);
        return new this.instantiator(data)  // <- uses typed constructor directly
    }

    get(id: string): T | null {
        const key = this.namespace + id
        const v = localStorage.getItem(key)
        if (v) {
            return this._deserialize(v)
        } else {
            return null
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use:

interface ThingLike {
    id?: string
    name?: string
}

class Thing implements Serializable {
    id: string
    name: string

    // initialize with object: new Thing({name:'Joe'})
    // note there is no need for separate static init method like `fromData`
    constructor(o:ThingLike) {
        this.id = o.id ? o.id : crypto.randomUUID()
        this.name = o.name ? o.name : ''
    }
}
Enter fullscreen mode Exit fullscreen mode

And use in a collection that deserializes these


const cc = LocalStoreCollection(Thing, 'things')  // <--- 

const thing = Thing({name: 'thing one'})
cc.add(thing)
const thing_again: Thing|null = cc.get(thing.id)
Enter fullscreen mode Exit fullscreen mode