Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typescript inconsistently fails to keep track of required string literal types through type conversions. #30798

Closed
JadedDragoon opened this issue Apr 6, 2019 · 3 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@JadedDragoon
Copy link

JadedDragoon commented Apr 6, 2019

TypeScript Version: 3.5.0-dev.20190406

Search Terms:
spread array literal string literal type ts(2741) ts(2322)

Code

// A *self-contained* demonstration of the problem follows...
// Test this by running `tsc` on the command-line, rather than through another build tool such as Gulp, Webpack, etc.

/**
 * Array literal constructor with '' as first element assigned to absPath.
 */
const bobHome: absPathBob = ['', 'home', 'bob'];                  //works
const timHome: absPathTim = ['', 'home', 'tim'];                  //works

/**
 * `absPathBob` (type alias) and `absPathTime` (interface) are not quite the
 * assignable to each other, though this isn't particularly unexpected.
 */
const bobNeighbor1: absPathBob = timHome;                         //Type 'absPathTim' is not assignable to type '["", ...string[]]'.ts(2322)
const timNeighbor1: absPathTim = bobHome;                         //works

/**
 * absPath->array typecasting via spread operators should be equivalent to the
 * original array literal constructor... but isn't
 */
const bobHome2: absPathBob = [...bobHome];                        //Property '0' is missing in type 'string[]' but required in type '["", ...string[]]'.ts(2741)
const timHome2: absPathTim = [...timHome];                        //Property '0' is missing in type 'string[]' but required in type 'absPathTim'.ts(2741)

/**
 * absPath->array typecasting via concatenation should also be equivalent to the
 * original array literal constructor... but also isn't
 */
const bobHome3: absPathBob = [].concat(bobHome);                  //Property '0' is missing in type 'any[]' but required in type '["", ...string[]]'.ts(2741)
const timHome3: absPathTim = [].concat(timHome);                  //Property '0' is missing in type 'any[]' but required in type 'absPathTim'.ts(2741)

/**
 *  Relative path for concatenating onto the end of home paths.
 */
const userMusic: relPathBob = ['Multimedia', 'Music'];            //works
const userVideo: relPathTim = ['Multimedia', 'Video'];            //works

/**
 * Using spread for concatenation of absPaths with relPaths fails.
 */
const bobMusic: absPathBob = [...bobHome, ...userMusic];          //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timVideo: absPathTim = [...timHome, ...userVideo];          //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * Using Array.prototype.concat() for concatenation of absPaths with relPaths
 * also fails.
 */
const bobMusic2: absPathBob = bobHome.concat(userMusic);          //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timVideo2: absPathTim = timHome.concat(userVideo);          //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * Appending string via spread->array fails.
 */
const bobConfig: absPathBob = [...bobHome, '.config'];            //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timSSH   : absPathTim = [...timHome, '.ssh'   ];            //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * Appending string via spread->array fails.
 */
const bobConfig: absPathBob = [...bobHome, '.config'];            //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timSSH   : absPathTim = [...timHome, '.ssh'   ];            //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * A dirty hack to trick typescript works with spread operator.
 */
const bobHax1: absPathBob = ['', ...bobHome.slice(1)];            //works
const timHax1: absPathTim = ['', ...bobHome.slice(1)];            //works

/**
 * Similar dirty hack fails with Array.prototype.concat()
 */
const bobHax12: absPathBob = [''].concat(bobHome.slice(1));       //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timHax12: absPathTim = [''].concat(bobHome.slice(1));       //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * WTF? What sorcery is this?
 */
const bobHax2: absPathBob = ['', ...bobHome.slice(1), 'hax'];     //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
const timHax2: absPathTim = ['', ...bobHome.slice(1), 'hax'];     //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)

/**
 * Array.prototype.shift() (as well as unshift, push, and pop) works fine...
 * but requires copying the absPath to a new absPath so as not to change the
 * original.
 */
const bobHax3: absPathBob = bobHome;                              //works
const timHax3: absPathTim = timHome;                              //works

bobHax3.shift();
timHax3.shift();

/**
 * Using an accumulator/for-loop pattern works.
 */
const bobLoop1: absPathBob = (() => {                              //works
    const accumulator = bobHome;
    for (let i = 0; i < userMusic.length; i++) accumulator.push(userMusic[i]);
    return accumulator;
})();

const timLoop1: absPathTim = (() => {                              //works
    const accumulator = timHome;
    for (let i = 0; i < userVideo.length; i++) accumulator.push(userVideo[i]);
    return accumulator;
})();

/**
 * But only if the accumulator starts out as an absPath
 */
const bobLoop2: absPathBob = (() => {                              //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
    const accumulator: string[] = []
    for (let i = 0; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userMusic.length; i++) accumulator.push(userMusic[i]);
    return accumulator;
})();

const timLoop2: absPathTim = (() => {                              //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)
    const accumulator: string[] = []
    for (let i = 0; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userVideo.length; i++) accumulator.push(userVideo[i]);
    return accumulator;
})();

const bobLoop3: absPathBob = (() => {                              //works
    const accumulator: absPathBob = ['']
    for (let i = 1; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userMusic.length; i++) accumulator.push(userMusic[i]);
    return accumulator;
})();

const timLoop3: absPathTim = (() => {                              //works
    const accumulator: absPathTim = ['']
    for (let i = 1; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userVideo.length; i++) accumulator.push(userVideo[i]);
    return accumulator;
})();

const bobLoop4: absPathBob = (() => {                              //Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)
    const accumulator: string[] = ['']
    for (let i = 1; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userMusic.length; i++) accumulator.push(userMusic[i]);
    return accumulator;
})();

const timLoop4: absPathTim = (() => {                              //Type 'string[]' is not assignable to type 'absPathTim'.ts(2322)
    const accumulator: string[] = ['']
    for (let i = 1; i < bobHome.length; i++) accumulator.push(bobHome[i]);
    for (let i = 0; i < userVideo.length; i++) accumulator.push(userVideo[i]);
    return accumulator;
})();



type relPathBob = [...string[]];
type absPathBob = ['', ...string[]];

interface relPathTim extends Array<string> {
    [n: number]: string;
}

interface absPathTim extends relPathTim {
    0: '';
}

Expected behavior:
If I can assign to an absPath using an array literal constructor which has '' as its first element... then I should also be able to take an absPath assigned with such an array literal constructor and use it with Array.prototype.concat() or via the spread operator as the first element of another array literal constructor to assign to a different absPath.

Actual behavior:
Actual behavior is highly inconsistent as described in the commented examples above.

An especially odd example is that array literals constructed with '' as the first element of the constructor and using an spread operator on an absPath as the second element of the constructor can be assigned to absPaths but, paradoxically, if there are any elements after the element containing the spread operator it fails again despite the first element explicitly being ''. Type 'string[]' is not assignable to type '["", ...string[]]'.ts(2322)

Playground Link:
Here

Related Issues:
Perhaps #26350 or #26350

Example Use Case
The basic idea should be pretty straight forward. Arrays of strings are paths. Arrays of strings beginning with '' are absolute paths. Given a hierarchical data structure (like the filesystem on a hard drive... or the DOM) every node has a specific path that identifies it. By adding and removing elements from the array you can "navigate" the filetree/DOM to find other nodes relative to the current one. Not having to write a loop or function with a loop in it to perform this "navigation" is significantly more convenient and significantly improves code clarity.

@JadedDragoon JadedDragoon changed the title Spreading variables of types extending Array and with a required element cannot be assigned to variables of the same type. Typescript inconsistently fails to keep track of required string literal types through type conversions. Apr 6, 2019
@jcalz
Copy link
Contributor

jcalz commented Apr 7, 2019

The issue I think of as possibly bugworthy here is primarily the unhelpful error message:

declare const syntheticAtLeastOneTuple: string[] & { "0": string };
const realAtLeastOneTuple: [string, ...string[]] = syntheticAtLeastOneTuple; // error!
//    ^^^^^^^^^^^^^^^^^^^
// Type 'string[] & { "0": string; }' is not assignable to type '[string, ...string[]]'.

This just raises the question of why it is not assignable. I assume it's because the compiler knows that the real tuple type has a length of "at least 1", which is currently not something you can synthesize in the type system as of TS3.4 (maybe number &not0 eventually?) But the error message doesn't say anything here other than "nope sorry".

Other instances of "X is not assignable to Y" with no further info: #21253, #25896, #29049

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Apr 10, 2019
@RyanCavanaugh
Copy link
Member

A lot going on here!

Seems like you're mostly observing the lack of tuple-preservation in spread; see #27859

[].concat(tuple) is unlikely to get special behavior since we'd have to do some very specific work around the empty array for this to be meaningful.

@JadedDragoon
Copy link
Author

JadedDragoon commented Apr 11, 2019

Ok lemme see if I can explain again more clearly. Neither absPath nor relPath are intended to be tuples. Tuples have a fixed length. Yes, both absPath and relPath must have one element (one string element, in fact... something I didn't think I'd need to track as undefined shouldn't be assignable to a string)... but they could have anywhere from one element to... whatever the limit for array length is in JavaScript. The requirement for absPath is that it's first element must be exactly equal to '' (as in absPath[0] === ''). Technically, relPath's first element must not be equivalent to '' (as in relPath[0] != ''),.. but I hadn't made it as far as trying to figure out how to represent that as a type yet.

Now I'll admit, I'm pretty new to Typescript. So maybe I've erred and the types/interfaces defined above don't match my intent. If that's the case then, yes, there absolutely needs to be a better error message. But the behavior in the example above is, as far as I can tell, really inconsistent regardless.

[] is just an empty array... an array with length === 0 and no elements. What special handling does it need? If you concatenate an empty array with a non-empty array... the result should be a shallow copy of the non-empty array. If you have a variable with a type which extends Array<string> and pass it to [].concat() the result should be an array with a shallow copy of the elements from your original variable... regardless of what the original variable's type was. I don't understand how any of that is "special". That's fairly mundane Javascript I thought.

Everything in the example above is me trying to find someway to not have to write a for loop to dis-assemble and reassemble the path every time I want to make a change to it. I can't even abstract such a for-loop to a function because I can't reliably assign the results from that function or assign to the parameters of that function. [...absPath] should do exactly that as the spread operator is essentially just a for-of loop where it returns each element. It's not the most performant way, certainly... but it's much more readable/maintainable than const startAbsPath = ['', 'current', 'path']; const destinatonRelPath = ['destination', 'path']; const accumulator = ['']; for (let i = 1; i < curAbsPath.length; i++) accumulator.push(curAbsPath[i]); for (let i = 0; i < destinationRelPath.length; i++) accumulator.push(destinationRelPath[i]); return accumulator;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

4 participants