TS infers "never" type because it can't grok assignment in forEach loop

Cam*_*urd 4 typescript

The error reads Property 'id' does not exist on type 'never'.

I understand enough about TypeScript to have thought that Control Flow Analysis would have me covered in this scenario. When I declare:

let quantityRange: QuantityLevels | false = false;
Run Code Online (Sandbox Code Playgroud)

And, while iterating through one of the function parameters, a QuantityLevels typed variable may be assigned:

priceList.quantityLevels.forEach((ql) => {
  if (ql.min <= quantity && ql.max >= quantity) {
    quantityRange = ql;
  }
});
Run Code Online (Sandbox Code Playgroud)

When we eliminate the possibility of false being the value assigned to quantityRange for the remainder of the function by throwing an error in that case. However, TypeScript infers that the variable quantityRange could not have been assigned anything other than false, and is typed as never for the remainder of the function.

When I try to use properties of quantityRange, I'm rebuffed:

const priceByProduct = priceList.prices[product.id][quantityRange.id].price;
// error: Property 'id' does not exist on type 'never'.
Run Code Online (Sandbox Code Playgroud)

What's going on here? If I make an assignment of

quantityRange = priceList.quantityLevels[0];
Run Code Online (Sandbox Code Playgroud)

(priceList.quantityLevels being typed as Array<QuantityLevels>), then all is well. This leaves me thinking that I must have an incomplete model of understanding when it comes to the way TypeScript deals with function/block-scoped variables. I've read through what I think are the relevant sections of the TypeScript handbook and have come up empty-handed (or perhaps empty-headed).

The code won't pass a build action, but in the development and test environments, it's working as expected.

The code below also rendered in TypeScript playground, along with errors.

function getPrice({ variantID, quantity, priceList }: GetPriceProps) {
  let quantityRange: QuantityLevels | false = false;

  priceList.quantityLevels.forEach((ql) => {
    if (ql.min <= quantity && ql.max >= quantity) {
      quantityRange = ql;
    }
  });

  // quantityRange = priceList.quantityLevels[0];
  // ^ Manually assigning this (which is what's happening in the above for loop) 
  //   makes TS understand.

  if (!quantityRange) {
    throw new Error(`No quantity range found for ${quantity} of ${variantID}.`);
  }

  const product = priceList.targets.find(
    (t) => t.variants.find((v) => v.id === variantID),
  );

  if (!product) {
    throw new Error(`Could not find a product related to variant ${variantID}.`);
  }

  const priceByProduct = priceList.prices[product.id][quantityRange.id].price;
  const priceByVariant = priceList.prices[product.id][quantityRange.id][variantID];

  return (priceByVariant || priceByProduct);
};

type QuantityLevels = {
  id: string;
  min: number;
  max: number;
}

interface NamedEntity {
  id: string;
  name: string; 
};

interface Product extends NamedEntity {
  variants: Array<NamedEntity>;
}

type PriceList = {
  targets: Array<Product>;
  quantityLevels: Array<QuantityLevels>;
  prices: {
    [productID: string]: {
      [quantityLevelID: string]: {
        price: number;
        [variantID: string]: number;
      }
    }
  }
};

type GetPriceProps = {
  variantID: string;
  quantity: number;
  priceList: PriceList;
};

Run Code Online (Sandbox Code Playgroud)

Obl*_*sys 5

Using forEach and the argument closure makes TypeScript less accurate with its control flow analysis. This TypeScript issue contains some more information.

If you replace

priceList.quantityLevels.forEach((ql) => {
  if (ql.min <= quantity && ql.max >= quantity) {
    quantityRange = ql;
  }
});
Run Code Online (Sandbox Code Playgroud)

with a for-of loop:

for (const ql of priceList.quantityLevels) {
  if (ql.min <= quantity && ql.max >= quantity) {
    quantityRange = ql;
  }
}
Run Code Online (Sandbox Code Playgroud)

的类型quantityRange被正确推断。

TypeScript 游乐场