1

For example, I've made a JavaScript library called lowclass and I'm wondering how to make it work in the TypeScript type system.

The library lets us define a class by passing in an object-literal into the API, like the following, and I'm wondering how to make it return a type that is effectively the same as writing a regular class {}:

import Class from 'lowclass'

const Animal = Class('Animal', {
  constructor( sound ) {
    this.sound = sound
  },
  makeSound() { console.log( this.sound ) }
})

const Dog = Class('Dog').extends(Animal, ({Super}) => ({
  constructor( size ) {
    if ( size === 'small' )
      Super(this).constructor('woof')
    if ( size === 'big' )
      Super(this).constructor('WOOF')
  },
  bark() { this.makeSound() }
}))

const smallDog = new Dog('small')
smallDog.bark() // "woof"

const bigDog = new Dog('big')
bigDog.bark() // "WOOF"

As you can see, the Class() and Class().extends() API accept object literals used for defining classes.

How can I type this API so that the end result is that Animal and Dog behave in TypeScript as if I had written them using native class Animal {} and class Dog extends Animal {} syntax?

I.e., if I were to switch the code base from JavaScript to TypeScript, how might I type the API in this case so that the end result is that people using my classes made with lowclass can use them like regular classes?

EDIT1: It seems an easy way to type classes I make with lowclass by writing them in JavaScript and declaring regular class {} definitions inside of .d.ts type definitions files. It seems more difficult, if even possible, to convert my lowclass code base to TypeScript so that it can make the typing automatic when defining classes rather than make .d.ts files for every class.

EDIT2: Another idea that comes to mind is that I can leave lowclass as is (JavaScript typed as any), then when I define classes, I can just define them using as SomeType where SomeType can be a type declaration right inside the same file. This might be less DRY than making lowclass be a TypeScript library so that types are automatic, as I'd have to re-declare methods and properties that I've already defined while using the lowclass API.

trusktr
  • 44,284
  • 53
  • 191
  • 263
  • Pretty sure you can make it very DRY with a generic. Something along the lines of `function Class(name: string, definition: T): ActualClass`. The tricky part are the constructor's arguments. You might need some repeating there. – Lazar Ljubenović Jun 17 '18 at 18:47
  • As for extending, I think it's too dynamic and too hack-y. TypeScript and metaprogramming don't play well together. – Lazar Ljubenović Jun 17 '18 at 18:48
  • I think we can get decent typing., better than any or redeclaring all the types Not sure if I'll get to it tonight, but if nobody else gets to it, I'll answer it in the morning :-) – Titian Cernicova-Dragomir Jun 17 '18 at 19:42

1 Answers1

2

Ok so there are several problems we need to fix for this to work in a similar way to Typescript classes. Before we begin, I do all of the coding below in Typescript strict mode, some typing behavior will not work without it, we can identify the specific options needed if you are interested in the solution.

Type and value

In typescript classes hold a special place in that they represent both a value (the constructor function is a Javascript value) and a type. The const you define only represents the value (the constructor). To have the type for Dog for example we need to explicitly define the instance type of Dog to have it usable later:

const Dog =  /* ... */
type Dog = InstanceType<typeof Dog>
const smallDog: Dog = new Dog('small') // We can now type a variable or a field

Function to constructor

The second problem is that constructor is a simple function, not a constructor function and typescript will not let us call a new on a simple function (at least not in strict mode). To fix this we can use a conditional type to map between the constructor and the original function. The approach is similar to here but I'm going to write it for just a few parameters to keep things simple, you can add more:

type IsValidArg<T> = T extends object ? keyof T extends never ? false : true : true;

type FunctionToConstructor<T, TReturn> =
    T extends (a: infer A, b: infer B) => void ?
        IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
        IsValidArg<A> extends true ? new (p1: A) => TReturn :
        new () => TReturn :
    never;

Building the type

With the type above we can now create the simple Class function that will take in the object literal and build a type that looks like the declared class. If here is no constructor field, we will asume an empty constructor, and we must remove the constructor from the type returned by the new constructor function we will return, we can do this with Pick<T, Exclude<keyof T, 'constructor'>>. We will also keep a field __original to have the original type of the object literal which will be useful later:

function Class<T>(name: string, members: T): FunctionToConstructor<ConstructorOrDefault<T>, Pick<T, Exclude<keyof T, 'constructor'>>> & { __original: T  }


const Animal = Class('Animal', {
    sound: '', // class field
    constructor(sound: string) {
        this.sound = sound;
    },
    makeSound() { console.log(this.sound) // this typed correctly }
})

This type in methods

In the Animal declaration above, this is typed correctly in the methods of the type, this is good and works great for object literals. For object literals this will have the type of the curent object in functions defined in the object literal. The problem is that we need to specify the type of this when extending an existing type, as this will have the members of the current object literal plus the members of the base type. Fortunately typescript lets us do this using ThisType<T> a marker type used by the compiler and described here

Creating extends

Now using contextual this, we can create the extends functionality, the only problem to solve is we need to see if the derived class has it's own constructor or we can use the base constructor, replacing the instance type with the new type.

type ReplaceCtorReturn<T, TReturn> =
    T extends new (a: infer A, b: infer B) => void ?
        IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
        IsValidArg<A> extends true ? new (p1: A) => TReturn :
        new () => TReturn :
    never;
function Class(name: string): {
    extends<TBase extends {
        new(...args: any[]): any,
        __original: any
    }, T>(base: TBase, members: (b: { Super : (t: any) => TBase['__original'] }) => T & ThisType<T & InstanceType<TBase>>):
        T extends { constructor: infer TCtor } ?
        FunctionToConstructor<ConstructorOrDefault<T>, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
        :
        ReplaceCtorReturn<TBase, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
}

Putting it all together:

type IsValidArg<T> = T extends object ? keyof T extends never ? false : true : true;

type FunctionToConstructor<T, TReturn> =
    T extends (a: infer A, b: infer B) => void ?
    IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
    IsValidArg<A> extends true ? new (p1: A) => TReturn :
    new () => TReturn :
    never;

type ReplaceCtorReturn<T, TReturn> =
    T extends new (a: infer A, b: infer B) => void ?
    IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
    IsValidArg<A> extends true ? new (p1: A) => TReturn :
    new () => TReturn :
    never;

type ConstructorOrDefault<T> = T extends { constructor: infer TCtor } ? TCtor : () => void;

function Class(name: string): {
    extends<TBase extends {
        new(...args: any[]): any,
        __original: any
    }, T>(base: TBase, members: (b: { Super: (t: any) => TBase['__original'] }) => T & ThisType<T & InstanceType<TBase>>):
        T extends { constructor: infer TCtor } ?
        FunctionToConstructor<ConstructorOrDefault<T>, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
        :
        ReplaceCtorReturn<TBase, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
}
function Class<T>(name: string, members: T & ThisType<T>): FunctionToConstructor<ConstructorOrDefault<T>, Pick<T, Exclude<keyof T, 'constructor'>>> & { __original: T }
function Class(): any {
    return null as any;
}

const Animal = Class('Animal', {
    sound: '',
    constructor(sound: string) {
        this.sound = sound;
    },
    makeSound() { console.log(this.sound) }
})

new Animal('').makeSound();

const Dog = Class('Dog').extends(Animal, ({ Super }) => ({
    constructor(size: 'small' | 'big') {
        if (size === 'small')
            Super(this).constructor('woof')
        if (size === 'big')
            Super(this).constructor('WOOF')
    },

    makeSound(d: number) { console.log(this.sound) },
    bark() { this.makeSound() },
    other() {
        this.bark();
    }
}))
type Dog = InstanceType<typeof Dog>

const smallDog: Dog = new Dog('small')
smallDog.bark() // "woof"

const bigDog = new Dog('big')
bigDog.bark() // "WOOF"

bigDog.bark();
bigDog.makeSound();

Hope this helps, let me know if I can help with anything more :)

Playground link

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Holy moly, this is a whole 'nother form of programming (like programming the types). Thanks for this insight. No questions yet. Gotta go learn this meta typing. – trusktr Jun 18 '18 at 21:06
  • @trusktr Yup it's another way of programming :) Fun fact, the typescript type system is Turing complete itself https://github.com/Microsoft/TypeScript/issues/14833 – Titian Cernicova-Dragomir Jun 19 '18 at 04:55
  • Hi Titian, I've been learning from my other questions (thanks for other answers!), and am starting to better understand all of this now. The actual problem is a little more difficult. Here's a more complete picture of what the `Class()` API looks like in plain JS: http://trusktr.io:7777/ebisimokak.js. As you can see there, there are also static, private, and protected members which are defined in sub-objects. Is it possible to type those private/protected/static members as such? – trusktr Jan 02 '19 at 07:08
  • That's quite impressive, I gotta say! A downside of this over regular classes is that when hovering over a variable (f.e. `bigDog`), it's not easy to understand the type tooltip. With regular classes, we'd see `const bigDog: Dog` in the tooltip, but in the playground example the tooltip shows: `const bigDog: Pick<{ sound: string; constructor(sound: string): void; makeSound(): void; }, "sound" | "makeSound"> & Pick<{ constructor(size: "small" | "big"): void; makeSound(d: number): void; bark(): void; other(): void; }, "makeSound" | ... 1 more ... | "other">`. I guess there's no way around that? – trusktr Jan 02 '19 at 07:19
  • @trusktr I think we can improve things even on the tooltip side.. The statics and privates look doable at first glance (I just woke up, so take that with a grain of salt :P). Contact me on gitter, it's hard to have a conversation in comments and this will probably take a lot of back and forth to get right.. – Titian Cernicova-Dragomir Jan 02 '19 at 07:24