【问题标题】:How can I get a correctly typed item from a collection of union types?如何从联合类型的集合中获取正确键入的项目?
【发布时间】:2020-08-03 19:20:22
【问题描述】:

我正在编写一个存储联合类型的自定义集合类。现在我想要一个访问器方法,它返回一个正确(单一)类型的项目。这是我想出的:

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 值。有什么方法可以让这段代码更安全?

我知道我可能需要运行时检查,但我想避免在每次调用 get 之后都必须调用 instanceof

另一种选择是为每种类型添加 get 方法,但这会违反开闭原则,因为对于每种新类型,我还必须添加一个 getter。

有没有更好的办法?

【问题讨论】:

标签: typescript generics collections


【解决方案1】:

我能想到的最接近的方法是使用discriminated unions,并将get 函数更改为实际接收type。将类型提供为泛型将在运行时无效。

// 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 

【讨论】:

    【解决方案2】:

    正如您所提到的,您将需要运行时检查。 TypeScript 的静态类型系统在编译为 JavaScript 时为 erased,因此 store.get&lt;GameA&gt;(0)store.get&lt;GameB&gt;(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() 某些错误类型的内容时,您将立即收到运行时错误。不过,我不确定这是否正是您要寻找的。​​p>


    也许您希望编译器在您get() 某些类型错误时给出错误,或者更好的是让编译器知道什么类型将在您致电get() 时。这意味着当您调用store.add(new GameA('Anton')) 时,编译器将修改 store 的类型,以记住它在索引0 处的参数是GameA 而不是GameBassertion functions 可以做到这一点,尽管使用起来有些痛苦:

    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() 在下一个索引处存储一个值;可能有更好的方法来做到这一点,但这只是一个草图。

    这就是我们如何使用它。断言函数最痛苦的部分是你必须在以前不需要的地方add explicit annotations

    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,那么编译器对其内容一无所知,你就会再次陷入运行时检查。


    无论如何,希望对您有所帮助;祝你好运!

    Playground link to code

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2018-06-26
      • 2020-02-05
      • 1970-01-01
      • 1970-01-01
      • 2018-07-29
      • 2015-10-31
      • 2022-07-19
      • 2011-07-09
      相关资源
      最近更新 更多