In object-oriented programming (OOP), we usually define an interface and implement that interface in different classes. In TypeScript, we can do the opposite. We can implement a class and derive an interface from that class without actually defining it. Why would we ever need to do this? Well, this can be very useful in various scenarios, such as creating type-safe mocks for JavaScript classes. This ensures that the mock class implements all the methods of the original class without having to define a separate interface.
Extracting the Instance Type
The first step is to extract the instance type from the class. By default, when you retrieve the type of a class in TypeScript, you get the type of the class constructor, not the type of an instance of the class. To work with the instance type, we'll define a utility type:
type ExtractInstanceType<T> = T extends new (...args: any[]) => infer R ? R : T extends { prototype: infer P } ? P : any;
Here ExtractInstanceType<T>
first tries to derive the instance type from the class constructor. If the constructor is not publicly accessible, the instance type is extracted from the prototype property of the class. This is the main difference to TypeScript's built-in utility type InstanceType<T>
, which only works with the public constructor.
For example, consider the following class with a private constructor:
class SomeClass {
private constructor() {}
someMethod() {}
}
//> OK: Type is SomeClass
type SomeClassInstance = ExtractInstanceType<typeof SomeClass>;
//> Error: Type 'typeof SomeClass' does not satisfy the constraint 'abstract new (...args: any) => any'.
type SomeClassInstanceError = InstanceType<typeof SomeClass>
Extracting Methods from the Instance Type
Once we have the instance type, we can extract its methods. To do this, we'll define two more utility types:
type ExtractMethodNames<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never }[keyof T];
type ExtractMethods<T> = Pick<T, ExtractMethodNames<T>>;
ExtractMethodNames<T>
is a mapped type that iterates over all keys of T
and checks if each key corresponds to a method. If it does, the key is preserved; otherwise, it's assigned the never
type.
ExtractMethods<T>
then uses the Pick
utility type to select only those properties from T that are methods.
Now, let's use these utility types to extract the methods from SomeClassInstance
:
//> Type contains methods from SomeClass
type SomeClassMethods = ExtractMethods<SomeClassInstance>;
In this example, SomeClassMethods
is a type that includes only the methods of an instance of SomeClass
.
Implementing a Class with the Extracted Methods
Finally, let's see how to define a new class that implements the SomeClassMethods
type. By making sure that a class is of type SomeClassMethods
, we ensure that the class has the same methods as an instance of SomeClass
.
Here's how you can do it:
//> OK: Class implements methods from SomeClass
class SomeClassMock implements SomeClassMethods {
someMethod() {}
}
//> Error: Class 'SomeClassMockError' incorrectly implements interface 'SomeClassMethods'.
//> Property 'someMethod' is missing in type 'SomeClassMockError' but required in type 'SomeClassMethods'
class SomeClassMockError implements SomeClassMethods {
anotherMethod() {}
}
In this code, SomeClassMock
is a new class that implements the SomeClassMethods
type. This ensures that SomeClassMock
includes a someMethod
function. If SomeClassMock
does not include a someMethod
function, or if the someMethod
function in SomeClassMock
does not match the one in SomeClassMethods
, TypeScript will throw a compilation error.
TypeScript Playground Example
The complete example is available on TypeScript Playground to test it yourself.
I hope you found this post helpful. If you have any questions or comments, feel free to leave them below. If you'd like to connect with me, you can find me on LinkedIn or GitHub. Thanks for reading!
Top comments (1)
Very interesting!