Как мы можем напечатать фабрику классов, которая генерирует класс с заданным литералом объекта?

Например, я сделал библиотеку JavaScript под названием lowclass и мне интересно, как заставить его работать в системе типов TypeScript.

Библиотека позволяет нам определить класс путем передачи объекта-литерала в API, как показано ниже, и мне интересно, как заставить его возвращать тип, который фактически совпадает с написанием обычного 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"

Как видите, Class() а также Class().extends() API принимает объектные литералы, используемые для определения классов.

Как я могу набрать этот API, чтобы конечный результат состоял в том, что Animal а также Dog ведут себя в TypeScript, как если бы я написал их, используя родной class Animal {} а также class Dog extends Animal {} синтаксис?

То есть, если бы я переключил кодовую базу с JavaScript на TypeScript, как бы я мог набрать API в этом случае, чтобы конечный результат состоял в том, что люди, использующие мои классы, созданные с использованием низкоуровневого класса, могут использовать их как обычные классы?

РЕДАКТИРОВАТЬ1: кажется, простой способ набирать классы, которые я делаю с низкими классами, написав их на JavaScript и объявив регулярные class {} определения внутри .d.ts файлы определений типов. Кажется более сложным, если вообще возможно, преобразовать мою кодовую базу низкого класса в TypeScript, чтобы она могла автоматически печатать при определении классов, а не делать .d.ts файлы для каждого класса.

РЕДАКТИРОВАТЬ 2: Другая идея, которая приходит в голову, состоит в том, что я могу оставить низкий класс как есть (JavaScript напечатан как any), тогда, когда я определяю классы, я могу просто определить их, используя as SomeType где SomeType может быть объявлением типа прямо в том же файле. Это может быть менее СУХОЕ, чем сделать lowclass библиотекой TypeScript, чтобы типы были автоматическими, так как мне пришлось бы повторно объявлять методы и свойства, которые я уже определил при использовании API низшего класса.

1 ответ

Итак, есть несколько проблем, которые нам нужно исправить, чтобы это работало аналогично классам Typescript. Прежде чем мы начнем, я делаю все кодирование ниже в Typescript strict В этом режиме некоторое поведение при наборе не будет работать, мы можем определить конкретные параметры, необходимые, если вы заинтересованы в решении.

Тип и стоимость

В классах машинописного текста особое место занимает то, что они представляют как значение (функция конструктора является значением Javascript), так и тип. const Вы определяете только представляет значение (конструктор). Иметь тип для Dog например, нам нужно явно определить тип экземпляра Dog чтобы его можно было использовать позже:

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

Функция для конструктора

Вторая проблема заключается в том, что constructor простая функция, а не функция конструктора, и машинопись не позволит нам вызвать new на простой функции (по крайней мере, не в строгом режиме). Чтобы исправить это, мы можем использовать условный тип для отображения между конструктором и исходной функцией. Подход похож на здесь, но я собираюсь написать его только для нескольких параметров, чтобы упростить задачу, вы можете добавить больше:

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;

Строим тип

С помощью приведенного выше типа мы можем теперь создать простой Class функция, которая принимает объектный литерал и создает тип, который выглядит как объявленный класс. Если здесь нет constructor поле, мы возьмем пустой конструктор, и мы должны удалить constructor из типа, возвращенного новой функцией конструктора, мы вернемся, мы можем сделать это с Pick<T, Exclude<keyof T, 'constructor'>>, Мы также будем держать поле __original иметь оригинальный тип литерала объекта, который будет полезен позже:

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 }
})

Этот тип в методах

в Animal декларация выше, this правильно набирается в методах типа, это хорошо и отлично работает для литералов объекта. Для литералов объекта this будет иметь тип текущего объекта в функциях, определенных в литерале объекта. Проблема в том, что нам нужно указать тип this при расширении существующего типа, как this будет иметь члены текущего литерала объекта плюс члены базового типа. К счастью, машинопись позволяет нам делать это, используя ThisType<T> тип маркера, используемый компилятором и описанный здесь

Создание расширяет

Теперь, используя контекстное это, мы можем создать extends функциональность, единственная проблема, которую нужно решить - нам нужно посмотреть, есть ли у производного класса собственный конструктор, или мы можем использовать базовый конструктор, заменив тип экземпляра новым типом.

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'>>>
}

Собираем все вместе:

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();

Надеюсь, это поможет, дайте мне знать, если я могу помочь с чем-нибудь еще:)

Ссылка на игровую площадку

Другие вопросы по тегам