Home > Net >  TypeScript doesn't throw an error when expected number of returned values is wrong
TypeScript doesn't throw an error when expected number of returned values is wrong

Time:01-03

For the sake of time and simplicity, the code examples in this question are simplified.

I have a function that takes in an array of objects and for each object in that array it creates a variation of a product. Then, it returns all of the variations as an array of those variation objects.

const generateProducts = (productMockData: object[]) => {
    const products = productMockData.map((mockData) => {
         // create a variation of the product based on mock data
         const productVariation = ...
         
         return productVariation;
    })

    return [...products]
}

Here's an example of how this function is used:

const [productVariation1, productVariation2] = generateProducts([{color: 'red', price: 20}, {color: 'green', price: 100}])

In the example above, we passed in an array of two objects to the generateProducts function, so the function returns an array of exactly two variation objects.

The issue is that if you accidentally expect an array of three objects to be returned from the function, instead of two, TypeScript doesn't throw an error.

const [productVariation1, productVariation2, productVariation3] = generateProducts([{color: 'red', price: 20}, {color: 'green', price: 100}])

In this case, we expect to have three variations of a product, but there are only two, since we only passed two to the generateProducts function. Still, TypeScript doesn't throw an error.

Is there a way to get better TypeScript support for this use case?

CodePudding user response:

The reason that your debuger is not trowing an error is that ists not an error... Yourself have to decide if this "interaction" is valid or not.

Your generateProducts function looks great but if you want to validate if the program is trying to have 3 variation based on 2 model object (the parameters given to the function) I think you have to fundmental choices:

1- Create a class that implements you generateProducts function and a property that will hold your array with null variables => [null,null,null] and then.

export new ProductionHelper {

  public array: Array < object | ProductsVariation | null > = [];

  public generateProducts(productMockData: Array < ProductsVariation > ) {

    if (this.array.lentgth === productMockData.length) {
      let i = 0;
      productMockData.map((mockData) => {
        // create a variation of the product based on mock data
        **
        createdObj **

          this.array[i] = createdObj;
      });
      return this.array;
    } else {
      throw [
        return
      ] new Error("Number of variation is not equal to length of array");
    }
    return;
  }
}

Second solution is to had your returned array has second parameter for your function =>

function generateProducts(productMockData: object[], productVar: object[])...

CodePudding user response:

In what follows I will ignore the implementation of generateProducts() and assume it has the following call signature:

declare const generateProducts: (productMockData: object[]) => object[];

Unless you enable the --noUncheckedIndexedAccess compiler option, the compiler will assume that you will always get a defined element if you read from an array type via a numeric index. It ignores the possibility that there is no element at the index (e.g., the index is negative, or not an integer, or past the end of the array, etc.) If you read the third element of an array that happens to have only two elements, you will get undefined at runtime but the compiler will completely miss this.

One thing you could do here is enable --noUncheckedIndexedAccess, which would address the situation by forcing you to deal with possible undefined values:

const [productVariation1, productVariation2, productVariation3] =
  generateProducts([{ color: 'red', price: 20 }, { color: 'green', price: 100 }])

if ("color" in productVariation3) { } // <-- error, Object is possibly 'undefined'
if (productVariation3 && "color" in productVariation3) { } // <-- okay

but this is often more trouble than it's worth; the same check will have to happen for productVariation1, which you know exists:

if ("color" in productVariation1) { } // error, grr
if (productVariation1 && "color" in productVariation1) { } // forcing me to check

which will tempt you to skip the check by using a non-null assertion:

if ("color" in productVariation1!) { } // <-- I assert this is fine

and if you get used to using such assertions, then you might overuse them, which leads you back to the same problem as before with productVariation3, with some extra steps:

if ("color" in productVariation3!) { } // <-- I assert this is fine, wait oops

And as such, the --noUncheckedIndexedAccess compiler option is not included in the --strict suite of compiler features. If you want to use it and it helps you, great. But otherwise, you might want to take a different approach.


A different way of looking at things is that your generateProducts() call signature currently returns an array of unknown length. But if you know that the returned array will always have the same length as the input array, then you can try to take advantage of tuple types; make generateProducts() a generic function which tries to figure out the exact length of its input (via variadic tuple type notation), and then have it return a mapped tuple type which of the same length (but possibly a different element type). For example:

declare const generateProducts: <T extends object[]>(
  productMockData: [...T]) => { [I in keyof T]: object };

And now when we call it with an array of known length, you can see that the compiler keeps that length in the output type:

const ret =
  generateProducts([{ color: 'red', price: 20 }, { color: 'green', price: 100 }]);
// const ret: [object, object]

And this immediately gives you the error you want and only where you want it:

const [productVariation1, productVariation2, productVariation3] = // error!
  // --------------------------------------> ~~~~~~~~~~~~~~~~~
  // Tuple type '[object, object]' of length '2' has no element at index '2'
  generateProducts([{ color: 'red', price: 20 }, { color: 'green', price: 100 }])

The compiler knows that productVariation1 and productVariation2 are defined, while productVariation3 is not:

// const productVariation1: object
// const productVariation2: object
// const productVariation3: undefined

This is pretty much exactly what you're looking for.

The big caveat here is that the compiler does not track the exact length of all arrays. If you pass an array literal directly into generateProducts(), the compiler will pay attention to the length, but in most cases an array literal will be widened to an array and not have its "tuple-ness" maintained:

const objects = [{ color: "red" }, { color: "green" }];
// const objects: { color: string; }[]

The above objects is an arbitrary array of {color: string} elements, and the compiler does not know how many such elements there are. If you pass that to generateProducts(), then you get the old behavior again:

const oops = generateProducts(objects);
// const oops: object[]

The compiler has no idea how many elements are in oops, and so if you destructure that into a bunch of variables, each one will either be of type object or of type object | undefined depending on the status of the --noUncheckedIndexedAccess compiler option.

This caveat probably shouldn't come as much of surprise; the situations in which a compiler can keep track of the exact number of elements in an array are pretty limited by necessity. Arrays are quite often used to hold an arbitrary and variable amount of data, and quite often are supplied by or modified by code that the compiler cannot access or fully analyze. So for the general case you're probably stuck with having the compiler be lax and let you read elements off the end of the array or strict and scold you into null-checking elements you know to be defined. But in the specific scenario like your example, where you write TypeScript code by calling a function and directly pass it an array literal of a known number of elements, you can get some stronger typing and nicer behavior.


Playground link to code

  • Related