Как я могу получить правильно набранный элемент из коллекции типов объединения? - PullRequest
1 голос
/ 03 августа 2020

Я пишу собственный класс коллекции, в котором хранится тип объединения. Теперь я хочу иметь метод доступа, который возвращает элемент с правильным (единственным) типом. Вот что я придумал:

class GameA {
    constructor(public name: string) {}
}

class GameB {
    constructor(public numberOfTries: number) {}
}

type AllGames = GameA | GameB;

class GameCollection {
    store: Array<AllGames>;
    constructor() {
        this.store = [];
    }
    add(g: AllGames) {
        this.store.push(g)
    }
    get<T extends AllGames>(idx: number): T {
        const item = this.store[idx];
        if (typeof item === 'undefined') {
            throw new Error("Index out of bounds");
        }
        // Any way to check at runtime that item is of type T?
        return item as T;
    }
}

const store = new GameCollection()
store.add(new GameA('Anton'))
store.add(new GameB(42))

console.log('First item (has name):', store.get<GameA>(0).name)
console.log('Second item (name is undefined):', store.get<GameA>(1).name)

Как видите, при использовании кода неправильного типа он получит undefined значения. Есть ли способ сделать этот код более безопасным по типу?

Я знаю, что мне, вероятно, понадобится проверка времени выполнения, но я хочу избежать необходимости вызывать instanceof после каждого вызова get.

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

Is есть ли способ лучше?

Ответы [ 2 ]

1 голос
/ 04 августа 2020

Как вы упомянули, вам потребуется проверка времени выполнения. Система типов TypeScript stati c: стирается при компиляции в JavaScript, поэтому обе строки store.get<GameA>(0) и store.get<GameB>(0) компилируются в store.get(0). Это означает, что у них нет возможности вести себя иначе, чем друг от друга.

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

get<T extends AllGames>(ctor: new (...args: any) => T, idx: number): T {
    const item = this.store[idx];
    if (typeof item === 'undefined') {
        throw new Error("Index out of bounds");
    }
    if (!(item instanceof ctor)) {
        throw new Error("Item is the wrong type");
    }
    return item;
}

Вызов не сильно отличается от предыдущего:

console.log('First item:', store.get(GameA, 0).name); // Anton
console.log('Second item:', store.get(GameA, 1).name); // runtime error

но теперь вы получите ошибку времени выполнения, как только попытаетесь get() что-то неправильного типа. Однако я не уверен, что это именно то, что вы ищете.

Возможно, вы хотите, чтобы компилятор выдавал ошибку, когда вы get() что-то не того типа, или, что еще лучше, чтобы компилятор знал какой тип будет при звонке на get(). Это означает, что когда вы вызываете store.add(new GameA('Anton')), компилятор изменяет тип store, чтобы помнить, что его аргумент с индексом 0 - это GameA, а не GameB. Это возможно с функциями утверждения , хотя использовать их несколько болезненно:

type Next = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
class GameCollection<N extends number = 0, A extends Record<number, AllGames> = {}> {
    store: Array<AllGames> & A;
    constructor() {
        this.store = [] as any;
    }
    add<T extends AllGames>(g: T): asserts this is GameCollection<Next[N], A & Record<N, T>> {
        this.store.push(g)
    }
    get<N extends keyof A>(idx: N): A[N] {
        return this.store[idx];
    }
}

Здесь мы удалили все проверки времени выполнения и вместо этого add() сузили тип store. Мне пришлось собрать Next, чтобы компилятор мог понять, что каждый add() сохраняет значение в следующем индексе; возможно, есть способ лучше, но это всего лишь набросок.

Итак, вот как мы будем его использовать. Самая болезненная часть функций утверждения заключается в том, что вам нужно добавлять явные аннотации в местах, которые вам не нужно было раньше:

const store: GameCollection = new GameCollection()
// ------> !!!!!!!!!!!!!!!!
// this annotation is NECESSARY;

Если вы проверяете store, это тип :

// const store: GameCollection<0, {}>

Затем посмотрите, что происходит после каждого add():

store.add(new GameA('Anton'));
store; // const store: GameCollection<1, Record<0, GameA>>
store.add(new GameB(42));
store; // const store: GameCollection<2, Record<0, GameA> & Record<1, GameB>>

Итак, теперь store можно использовать следующим образом:

console.log('First item:', store.get(0).name); // okay
console.log('Second item:', store.get(1).name); // error!
// ------------------------------------> ~~~~
// no name on GameB
store.get(2); // error! 
// -----> ~
// 2 is not assignable to 0 | 1

Я думаю, что это изящно, но в целом это, вероятно, не самая лучшая идея, если только вы не планируете создавать и использовать эти коллекции внутри связанных частей кода TypeScript. Если кто-то передаст вам неизвестный GameCollection, то компилятор ничего не знает о его содержимом, и вы снова застрянете, выполняя проверки во время выполнения.

В любом случае, надеюсь, что это поможет; удачи!

Детская площадка ссылка на код

1 голос
/ 04 августа 2020

Самое близкое, что я могу придумать, - это использование размеченных объединений и изменение функции get, чтобы она фактически принимала type. Если вместо этого указать тип как generi c, это не повлияет на выполнение.

// Making it so there's a `type` field that can be discriminated on at runtime 
class GameA {
  constructor(name: string) {
    this.name = name
  }
  name: string
  type: 'GameA' = 'GameA'
}

class GameB {
  constructor(numberOfTries: number) {
    this.numberOfTries = numberOfTries
  }
  numberOfTries: number
  type: 'GameB' = 'GameB'
  name: undefined
}

type AllGames = GameA | GameB

class GameCollection {
  store: AllGames[]
  constructor() {
    this.store = []
  }
  add(g: AllGames) {
    this.store.push(g)
  }
  get(idx: number, type: AllGames['type']) { // updating so it takes a type argument that's available at runtime
    const item = this.store[idx]
    if (!item) {
      throw new Error('Index out of bounds')
    }

    if (item.type === type) {
      return item
    }
    throw new Error('No Game of type ' + type + ' at index ' + idx)
  }
}

const store = new GameCollection()
store.add(new GameA('Anton'))
store.add(new GameB(42))

console.log('First item (has name):', store.get(0, 'GameA').name) // name is correctly typed to undefined | string 
console.log('Second item (name is undefined):', store.get(1, 'GameA').name) // name is correctly typed to undefined | string 

...