Description
Suggestion
Proposal to expose zeroType
(as getZeroType()
), emptyStringType
(as getEmptyStringType()
) and isTypeAssignableTo
on the TS TypeChecker
π Search Terms
#zeroType, #emptyStringType, #isTypeAssignableTo
β Viability Checklist
Searched issues for: zeroType, emptyStringType, isTypeAssignableTo
There are two open issues discussing the request to expose isTypeAssignableTo
, but it appears to have fizzled out. Links here:
#11728 and #9879
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This feature would agree with the rest of TypeScript's Design Goals.
β Suggestion
The TS TypeChecker already exposes getNullType()
, getFalseType()
, and getUndefinedType()
. I'm using all of these in an ESLint rule I'm working on. However, I have no trivial way to get at the 0
type or the ''
(empty string) type. The
The zeroType
and the emptyStringType
do indeed appear to exist in the TS code (checker.ts lines 1011 and 1012):
const emptyStringType = getStringLiteralType("");
const zeroType = getNumberLiteralType(0);
But they do not appear to be exposed like getNullType()
, getUndefinedType()
, and getFalseType()
.
Presently, I have a dirty dirty hack where I create a program in memory and yank out the types, as in:
function createZeroAndEmptyStringTypes(compilerOptions) {
const code = `
const zero: 0 = 0;
const emptyString: '' = '';`;
// NOTE: no files are actually written to disk, despite the name of this method.
// This process happens entirely in memory.
const sourceFile = ts.createSourceFile('doesnotmatter.ts', code, compilerOptions.target);
const compilerHost = {
getSourceFile: (name, languageVersion) => sourceFile,
writeFile: (filename, data) => {},
getDefaultLibFileName: () => 'lib.d.ts',
useCaseSensitiveFileNames: () => false,
getCanonicalFileName: (filename) => filename,
getCurrentDirectory: () => '',
getNewLine: () => '\n',
getDirectories: () => [],
fileExists: () => true,
readFile: () => '',
getCompilerOptions: () => compilerOptions
};
const program = ts.createProgram([sourceFile.fileName], compilerOptions, compilerHost);
const checker = program.getTypeChecker();
let zeroType, emptyStringType;
ts.forEachChild(sourceFile, visit);
return [zeroType, emptyStringType];
function visit(node) {
if (ts.isVariableDeclaration(node)) {
const type = checker.getTypeAtLocation(node);
const name = node.symbol?.escapedName;
if (name === 'emptyString') {
emptyStringType = type;
} else if (name === 'zero') {
zeroType = type;
} else {
throw new Error(`Unexpected symbol name: ${name} while creating falsy types of emptyString and zero`);
}
}
ts.forEachChild(node, visit);
}
}
Besides being pretty ugly, probably inefficient and just generally offending my sensibilities, this has some very practical drawbacks. For instance, any 0
type retrieved via getTypeAtLocation()
from the actual code being linted, will not be equal to the poorly generated zeroType
I create in my dirty function above (same for emptyStringType
). In fact, even calling isTypeAssignableTo(zeroType, realZeroTypeFromLintedCode)
will return false! I suspect there may be other cases as well, where is it unsafe/unreliable to use these generated types and expect them to behave in a reliable way within the program actually being linted.
π Motivating Example
The main motivation is to support an effort to create a new ESLint rule. Proposal for this rule is available here: typescript-eslint/typescript-eslint#5592
There you will find a (collapsed) list of ~50ish test cases that illustrate what the rule should do. These test cases are likely the most insightful bit of code in terms of understanding the spirit of the rule.
π» Use Cases
As the ESLint rule needs to identify cases where a type is assignable one (and only one) falsy value, exposing getZeroType()
and getEmptyStringType()
would eliminate the dirty code I posted above. Here is an snippet from the rule's code of how the type would be used:
function isEligibleForShortening: (leftType, rightType) => {
const rightSideIsFalsy = TSUtils.isFalsyType(rightType);
const leftSideFalsies = [...falsies].filter((falsy) => checker.isTypeAssignableTo(falsy, leftType));
const leftSideAcceptsExactlyOneFalsy = leftSideFalsies.length === 1;
return (
rightSideIsFalsy &&
leftSideAcceptsExactlyOneFalsy &&
leftSideFalsies[0] === rightType // THIS IS NOT CURRENTLY POSSIBLE
);
}
In the above snippet, leftSideFalsies[0] === rightType
does not always work. When rightType (which actually retrieved from the code being linted) is compared against a generated zeroType
or emptyStringType
, this check fails. To work around this, the actual code being used today is:
function areEffectivelyEqual(type1, type2) {
return (
type1 === type2 || (TSUtils.isLiteralType(type1) && TSUtils.isLiteralType(type2) && type1.value === type2.value)
);
}
This appears to work (for my needs) but it gives me a similarly yucky feeling to the dirty function that generates the zeroType and the emptyStringType (which is the whole reason this hack is needed in the first place).