typescriptdestructuringtypescript-eslint

Why I'm receiving an "Unsafe array destructuring" on an object destructuring in TypeScript?


I have a problem in TypeScript with an IteratorResult destructuring. I'm doing a simple object destructuring and I'm getting an array destructuring error.

This is the code that is throwing the TypeScript error:

class ServiceLabelCollection extends ICollection<ServiceLabel> {
    private collection: Collection<ServiceLabel>;
    
    public addSoft(labels: ServiceLabel | Iterable<ServiceLabel>): this {
        const isIterable = !(labels instanceof ServiceLabel);
        const toAddIterable: Iterable<ServiceLabel> = isIterable ? labels : [labels];
        const iterator: Iterator<ServiceLabel> = toAddIterable[Symbol.iterator]();
    
        while (this.collection.size < ServiceLabelsCollection.maxSize) {
            const { value, done }: IteratorResult<ServiceLabel> = iterator.next(); // Unsafe array destructuring of a tuple element with an `any` value. eslint@typescript-eslint/no-unsafe-assignment
            
            if (done) break;
            this.collection.addSoft(value);
        }
    
        return this;
    }
}

The issue seems to be a bug, because it is an object destructuring, and I think it's well implemented. The other reason why I think it could be an TypeScript bug is due to the next code does not give the error:

class ServiceLabelCollection extends ICollection<ServiceLabel> {
    private collection: Collection<ServiceLabel>;
    
    public addSoft(labels: ServiceLabel | Iterable<ServiceLabel>): this {
        const isIterable = !(labels instanceof ServiceLabel);
        const toAddIterable: Iterable<ServiceLabel> = isIterable ? labels : [labels];
        const iterator: Iterator<ServiceLabel> = toAddIterable[Symbol.iterator]();
    
        while (this.collection.size < ServiceLabelsCollection.maxSize) {
            const result: IteratorResult<ServiceLabel> = iterator.next(); // Simply I didn't do destructuring
            
            if (result.done) break;
            this.collection.addSoft(result.value);
        }
    
        return this;
    }
}

Do someone see an error that I could be doing here? Or someone else guess that it could be a TypeScript bug?


Solution

  • TLDR: Don't manually type everything. It's ok to let typescript infer things. You've actually used the wrong types here which caused a type to be widened to any, which eslint is now yelling at you about.


    The actual problem

    Unsafe array destructuring of a tuple element with an any value. eslint@typescript-eslint/no-unsafe-assignment

    This is not a typescript error. It's an eslint error. And the root of the problem is that the type of result.value is any. The destructuring just causes eslint to notice that.


    Finding the right type to use

    So the real problem is that result.value is typed as any. Let's fix that.

    I've reduced your code to this:

    type ServiceLabel = { label: string }
    const array: ServiceLabel[] = []
    const iterator = array[Symbol.iterator]()
    
    const { value, done }: IteratorResult<ServiceLabel> = iterator.next();
    //      ^ any
    

    Playground

    Now in your IDE (or in the typescript playground) if you hover over IteratorResult in that snippet, you will see this type:

    type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>
    

    Note that the second parameter defaults to any when unspecified. And then note that you only passed one parameter.

    Now hover over .next() and you will see this type:

    Iterator<ServiceLabel, undefined, unknown>.next(...[value]: [] | [unknown]):
      IteratorResult<ServiceLabel, undefined>
    

    Which says that iterator.next() is returning this type:

    IteratorResult<ServiceLabel, undefined>
    

    Which is different than the type you chose. If you change your type to IteratorResult<ServiceLabel, undefined> then everything should work as you expect.

    const { value, done }: IteratorResult<ServiceLabel, undefined> = iterator.next();
    

    Or just let it be inferred, because in typescript you should't be providing a type to every single value.

    const { value, done } = iterator.next();
    

    See playground


    What's going on here?

    If you dive into the types for IteratorResult (cmd/ctrl+click the type in your IDE) it paints a bigger picture:

    interface IteratorYieldResult<TYield> {
        done?: false;
        value: TYield;
    }
    
    interface IteratorReturnResult<TReturn> {
        done: true;
        value: TReturn;
    }
    
    type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
    

    An iterator has two ways it may return values. The "yield" result is what you typically expect the iterator to be iterating over, and the "return" result is the value for when the iterator is "done".

    In the case of an ArrayIterator, the "yield" type is the type of members of the array, and the "return" result is undefined because when you are the end of the array, there's no items left to return.

    But if you manually use an IteratorResult type, you have to provide both types to avoid any, because the TReturn type defaults to any. Which blows up this union type:

    IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
    
    // reduces to
    IteratorYieldResult<T> | IteratorReturnResult<any>;
    
    // reduces to
    { done?: false, value: T } | { done: true, value: any }
    
    // If you access `value` of that union type, you get:
    T | any
    
    // which reduces to
    any
    

    Because any union with any is any. For example type A = number | any // any.