This was a bit of a doozy and took me the better half of a day to find a semi decent fix for it.
Here's the unsuspecting use case and why it caused problems (try it out on Typescript playground)
// --- Type definitions | |
type SingleConfig = { | |
[key: string]: any; | |
// [index: number]: never; // Works fine if you don't use code like [variable.name] below | |
lastIndexOf?: never; // Correctly forces detection of arrays | |
} | |
type MultiConfig = SingleConfig[]; | |
// --- The subtle, bug causing function | |
function conditionalReturn(gimme: 'single' | 'multiple'): SingleConfig | MultiConfig { | |
if (gimme === 'single') { | |
return { single: 'value' }; | |
} | |
else { | |
return [ | |
{ multiple: 'values' }, | |
{ and: 'more!' }, | |
]; | |
} | |
} | |
// --- The unknowing usage | |
function onlyWorksWithSingleConfig(config: SingleConfig) { | |
console.log('onlyWorksWithSingleConfig is array', Array.isArray(config), config); | |
} | |
function isSingleType(what: any): what is SingleConfig { | |
return !Array.isArray(what); | |
} | |
function doSomething() { | |
const single = conditionalReturn('single'); | |
onlyWorksWithSingleConfig(single); // This should complain | |
const multiple = conditionalReturn('multiple'); | |
onlyWorksWithSingleConfig(multiple); // This should complain too | |
const variable = { name: 'santa', value: 'clause' }; | |
const variableConfig: SingleConfig = { | |
fixed: 'value', | |
[variable.name]: variable.value, | |
}; | |
// Use of type guards failed | |
if (isSingleType(single)) { | |
onlyWorksWithSingleConfig(single); // This is fine | |
} | |
} |
- SingleConfig and MultiConfig are different types. One is a single object value, the other is an array of that.
- conditionalReturn() returns either one of those depending on the input.
- onlyWorksWithSingleConfig() as the name states will only work with an instance of SingleConfig
- Typescript doesn't seem to detect an issue when using onlyWorksWithSingleConfig() with the result from conditionalReturn()
Why? Because Javascript is a hot mess when Arrays are considered to be objects. It is possible that Typescript allows that behaviour because Javascript arrays are keyed by strings (not numbers) so it will fit the type interface specified by SingleConfig.
For instance, look at this output.
So now that we have a possible reason why this is allowed in Typescript, how can we prevent it?
Attempt 1 - use type guards
As documented here, I tried using them to enforce some sort of sanity. It did not work.
Attempt 2 - disallow [index: number] in SingleConfig
I found this on StackOverflow and thought it was an elegant solution to the problem, however quickly realised it broke a valid use case where this syntax would be marked as incorrect.
Attempt 3 - Disallow "random array prototype member" ✔
A smart fellow at work suggested being a bit more specific with the "never" clause.
Instead of trying to capture all the cases, why not filter out a specific case, like "length"? (Just to be safe, I used something a bit more weird like "lastIndexOf" to avoid potential issues)
And it worked! Just like that, Typescript is now correctly detecting the two separate types.
It even fixed up the type guard function isSingleType() !