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

Issue

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;

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

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

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

quantityRange = priceList.quantityLevels[0];

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

Solution

Use a type assertion when instantiating the variable instead of an annotation. This will prevent TS from inferring that it is only false thereafter in the rest of the scope, even in code blocks which present difficulties regarding control flow analysis.

let quantityRange = false as QuantityLevels | false;

Your modified code in the TS Playground

Answered By – jsejcksn

Answer Checked By – Katrina (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *