0

I'm attempting to create a factory function which accepts an array of { item: T; weight: number } and returns an object with a method pick, which in turn would return either T or T[], based on whether it is called with a defined quantity option or not:

type PickOneOptions = {
  quantity: undefined;
};

type PickManyOptions = {
  quantity: number;
};

type PickOptions = PickOneOptions | PickManyOptions;

type WeightedItem<T> = { item: T; weight: number };

type WeightedTable<T> = {
  pick: {
    (): T;
    (options: PickOneOptions): T;
    (options: PickManyOptions): T[];
  };
};

However, using my current type definitions, TypeScript complains that 'T' could be instantiated with an arbitrary type which could be unrelated to 'T | T[]'. in the method's implementation. Interestingly, casting either of the return values to any (either one of the two possible branches) silences the issue.

The return values do work as expected whether I silence the error or not:

const createWeightedTable = <T>(items: WeightedItem<T>[]): WeightedTable<T> => {
  const totalWeight = items.reduce((prev, curr) => prev + curr.weight, 0);

  return {
    pick: (options?: PickOptions) => { // 'T' could be instantiated with an arbitrary type which could be unrelated to 'T | T[]'.
      const { quantity } = options ?? {};
      const weightedItems = items;

      let random = Math.random() * totalWeight;

      if (quantity === undefined) {
        for (const weightedItem of weightedItems) {
          random -= weightedItem.weight;

          if (random <= 0) {
            return weightedItem.item;
          }
        }

        throw new Error();
      }

      const result: T[] = [];

      for (let i = 0; i < quantity; i++) {
        for (const weightedItem of items) {
          random -= weightedItem.weight;

          if (random <= 0) {
            result.push(weightedItem.item);
          }
        }
      }

      return result;
    },
  };
};

const table = createWeightedTable([
  {
    item: 'a',
    weight: 1
  },
  {
    item: 'b',
    weight: 1
  }
])

const resultOne = table.pick(); // string

const resultMany = table.pick({ quantity: 2 }); // string[]

Playground

What's causing that error? And how could I fix it without resorting to type assertion?

Tiago
  • 4,233
  • 13
  • 46
  • 70
  • 2
    Why would you not use an overload? Also the code in your question doesn't really show the issue, the issue appears to be that you are returning a concrete type from a function with a generic return type? – Jared Smith Aug 15 '23 at 12:14
  • I am using overloads. Not sure what you mean by the second part of your comment. Check the playground link for a more complete overview. – Tiago Aug 15 '23 at 12:21
  • "I am using overloads" I don't think you are, [this is the syntax for method overloads](https://stackoverflow.com/a/13212871/3757232). If you change your notation to match that in your playground you will notice that the error message changes to include "+2 overloads". – Jared Smith Aug 15 '23 at 12:46
  • 2
    You're running into [ms/TS#47669](//github.com/microsoft/TypeScript/issues/47669) because you're trying to implement an overload with an arrow function. You could rewrite to an overloaded function statement as in [this playground link](//tsplay.dev/NBBkbN). Note that in terms of type safety this isn't really better than "casting" (TS uses the term "type assertion" for this), see [ms/TS#13235](//github.com/microsoft/TypeScript/issues/13235), but that's just how overloads in TS are. Does that fully address the question? If so then I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 15 '23 at 13:05
  • Oh, thank you @jcalz. I believe that does fully address this question. I am still curious about one thing however: why do I only need to assert one of the branches as `any` in order to silence the problem and not both? – Tiago Aug 15 '23 at 13:13
  • Or even further, why does asserting the branches as `T` for singular and `T[]` for plural not silence the error as well? – Tiago Aug 15 '23 at 13:14
  • 1
    I'm getting a little overwhelmed with the number of questions here. `any` in one branch fixes the issue because `any | XXX` is just `any`, and `any` is a "stop type checking here" marker. Asserting the branches with their correct types doesn't work because ms/TS#47669; overloads are not implementable with arrow functions directly, so it doesn't make any attempt to do a "if this input then that output" check; indeed it *never* does this, it's either too strict (ms/TS#47669) or too loose (ms/TS#13235). I'm hoping I can focus on writing up an answer now. – jcalz Aug 15 '23 at 13:18
  • 1
    @jcalz sorry about that. Go ahead. – Tiago Aug 15 '23 at 13:48
  • While I do so, please put your code as plaintext in the question itself, so that the error you're talking about is present there. A playground link is a nice supplement but it doesn't suffice to be a [mre]. Thanks! – jcalz Aug 15 '23 at 15:15

1 Answers1

2

You've made the pick property of WeightedTable<T> an overloaded method with multiple call signatures. As such, Typescript currently does not really support implementing it with an arrow function as you've done. This is a known issue, described at microsoft/TypeScript#47669. The easiest way to fix it is to replace it with a function statement:

const createWeightedTable = <T>(items: WeightedItem<T>[]): WeightedTable<T> => {
  const totalWeight = items.reduce((prev, curr) => prev + curr.weight, 0);

  function pick(): T;
  function pick(options: PickOneOptions): T;
  function pick(options: PickManyOptions): T[];
  function pick(options?: PickOptions) {    
    const { quantity } = options ?? {};
    const weightedItems = items;    
    let random = Math.random() * totalWeight;    
    if (quantity === undefined) {
      for (const weightedItem of weightedItems) {
        random -= weightedItem.weight;    
        if (random <= 0) { return weightedItem.item; }
      }
      throw new Error(); // should never happen
    }    
    const result: T[] = [];    
    for (let i = 0; i < quantity; i++) {
      for (const weightedItem of items) {
        random -= weightedItem.weight;    
        if (random <= 0) { result.push(weightedItem.item); }
      }
    }    
    return result;
  }

  return { pick }; // okay
};

The implementation is unchanged, because the call signatures are declared to match the required type, then the compiler is happy when you return an object with the function as the pick method.


Do note that overloads are a rough spot in TypeScript's type system in multiple ways. The compiler does not really try to verify that a function implementation meets all the separate call signatures. Instead you are forced to choose between potential false positives (errors for safe code) and potential false negatives (no errors for unsafe code). Take a simple case like

function flip(x: true): false;
function flip(x: false): true;
function flip(x: boolean) {
  return !x
}

That compiles, but not because the compiler has followed the intended logic. Instead all it does is make sure that the implementation accepts all possible calls (so x is true or x is false) and that the outputs cover the possible calls (so either false or true), but it doesn't actually care which outputs go with which inputs. So there's no error here:

function flip(x: true): false;
function flip(x: false): true;
function flip(x: boolean) {
  return x; // <-- oops!
}

This is discussed at microsoft/TypeScript#13235; it is considered too complex to try to make the compiler accurately verify overload implementations. So you get this too-lax behavior.

So while pick is now accepted using an overloaded function statement, you need to be careful that you've implemented it properly. Return the wrong output for an input, and it will still properly compile.

On the other hand, when you try to start assigning functions with a single call signature to places that expect an overload, you get the other problem: the compiler cannot verify that it meets the overload, and it complains even for safe code:

const flipArrow: typeof flip = x => !x // error!

That's the problem you ran into originally with pick. You can also choose to address this via type assertion with something like the any type to disable type checking of the expression:

const flipArrow: typeof flip = x => !x as any // okay

which is just another way of trading potential false positives for potential false negatives.


So no matter what you do here, you're not going to get the sort of compiler-verified type safety you might hope for, and it's probably just personal preference whether you want to refactor to too-lax function statements or add a too-lax type assertion.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360