Skip to content

Commit

Permalink
Merge pull request #100 from blazejkustra/prefer-type-fest
Browse files Browse the repository at this point in the history
Custom ESLint rule: Prefer type fest
  • Loading branch information
roryabraham committed Jun 10, 2024
2 parents 99b8a6f + 8615b10 commit a8951a6
Show file tree
Hide file tree
Showing 6 changed files with 1,318 additions and 480 deletions.
2 changes: 2 additions & 0 deletions eslint-plugin-expensify/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ module.exports = {
USE_PERIODS_ERROR_MESSAGES: 'Use periods at the end of error messages.',
USE_DOUBLE_NEGATION_INSTEAD_OF_BOOLEAN: 'Use !! instead of Boolean().',
NO_ACC_SPREAD_IN_REDUCE: 'Avoid a use of spread (`...`) operator on accumulators in reduce callback. Mutate them directly instead.',
PREFER_TYPE_FEST_TUPLE_TO_UNION: 'Prefer using `TupleToUnion` from `type-fest` for converting tuple types to union types.',
PREFER_TYPE_FEST_VALUE_OF: 'Prefer using `ValueOf` from `type-fest` to extract the type of the properties of an object.',
},
};
115 changes: 115 additions & 0 deletions eslint-plugin-expensify/prefer-type-fest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable es/no-optional-chaining */
const {AST_NODE_TYPES} = require('@typescript-eslint/utils');
const {PREFER_TYPE_FEST_VALUE_OF, PREFER_TYPE_FEST_TUPLE_TO_UNION} = require('./CONST').MESSAGE;
const {addNamedImport} = require('./utils/imports');

const rule = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce using type-fest utility types for better type readability',
},
fixable: 'code',
},
create(context) {
let typeFestImport;

return {
Program(node) {
// Find type-fest import declarations
node.body.forEach(statement => {
if (statement.type === 'ImportDeclaration' && statement.source.value === 'type-fest') {
typeFestImport = statement;
}
});
},
TSIndexedAccessType(node) {
const objectType = node.objectType;
const indexType = node.indexType;

// Ensure that both objectType and indexType exist
if (!objectType || !indexType) {
return;
}

// Ensure that objectType is of TSTypeQuery type
if (objectType?.type !== AST_NODE_TYPES.TSTypeQuery) {
return;
}

// Case for when the object type is a plain identifier (COLORS)
if (objectType?.exprName?.type === AST_NODE_TYPES.Identifier) {
const objectTypeText = context.getSourceCode().getText(objectType.exprName);

// Ensure that indexType is keyed by type 'keyof' ((typeof COLORS)[keyof COLORS])
if (indexType?.type === AST_NODE_TYPES.TSTypeOperator && indexType?.operator === 'keyof') {
// Ensure that the object type is the same as the index type and both exist

const indexTypeText = context.getSourceCode().getText(indexType.typeAnnotation.typeName);
if (objectTypeText && objectTypeText === indexTypeText) {
context.report({
node,
message: PREFER_TYPE_FEST_VALUE_OF,
fix: (fixer) => {
const fixes = [fixer.replaceText(node, `ValueOf<typeof ${objectTypeText}>`)];
fixes.push(...addNamedImport(context, fixer, typeFestImport, 'ValueOf', 'type-fest', true));
return fixes;
}
});
}
}

// Ensure that indexType is keyed by type 'number' ((typeof STUFF)[number])
if (indexType?.type === AST_NODE_TYPES.TSNumberKeyword) {
context.report({
node,
message: PREFER_TYPE_FEST_TUPLE_TO_UNION,
fix: (fixer) => {
const fixes = [fixer.replaceText(node, `TupleToUnion<typeof ${objectTypeText}>`)];
fixes.push(...addNamedImport(context, fixer, typeFestImport, 'TupleToUnion', 'type-fest', true));
return fixes;
}
});
}
}

// Case for when the object type is a nested object (CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS)
if (objectType?.exprName?.type === AST_NODE_TYPES.TSQualifiedName) {
const objectTypeText = context.getSourceCode().getText(objectType.exprName);

// Ensure that indexType is keyed by type 'keyof' ((typeof CONST.VIDEO_PLAYER)[keyof CONST.VIDEO_PLAYER])
if (indexType?.type === AST_NODE_TYPES.TSTypeOperator && indexType?.operator === 'keyof') {
const indexTypeText = context.getSourceCode().getText(indexType.typeAnnotation.exprName);

if (objectTypeText && objectTypeText === indexTypeText) {
context.report({
node,
message: PREFER_TYPE_FEST_VALUE_OF,
fix: (fixer) => {
const fixes = [fixer.replaceText(node, `ValueOf<typeof ${objectTypeText}>`)];
fixes.push(...addNamedImport(context, fixer, typeFestImport, 'ValueOf', 'type-fest', true));
return fixes;
}
});
}
}

// Ensure that indexType is keyed by type 'number' ((typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS)[number])
if (indexType?.type === AST_NODE_TYPES.TSNumberKeyword) {
context.report({
node,
message: PREFER_TYPE_FEST_TUPLE_TO_UNION,
fix: (fixer) => {
const fixes = [fixer.replaceText(node, `TupleToUnion<typeof ${objectTypeText}>`)];
fixes.push(...addNamedImport(context, fixer, typeFestImport, 'TupleToUnion', 'type-fest', true));
return fixes;
}
});
}
}
},
};
},
};

module.exports = rule;
100 changes: 100 additions & 0 deletions eslint-plugin-expensify/tests/prefer-type-fest.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const RuleTester = require('eslint').RuleTester;
const rule = require('../prefer-type-fest');
const {PREFER_TYPE_FEST_VALUE_OF, PREFER_TYPE_FEST_TUPLE_TO_UNION} = require('../CONST').MESSAGE;

const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
},
});

ruleTester.run('prefer-type-fest', rule, {
valid: [
{
code: 'const STUFF = [\'a\', \'b\', \'c\'] as const; type Good = TupleToUnion<typeof STUFF>;',
parser: require.resolve('@typescript-eslint/parser'),
},
{
code: 'const STUFF = [\'a\', \'b\', \'c\'] as const; type Good = Record<string, TupleToUnion<typeof TIMEZONES>>;',
parser: require.resolve('@typescript-eslint/parser'),
},
{
code: 'const CONST = { VIDEO_PLAYER: { PLAYBACK_SPEEDS: [0.25, 0.5, 1, 1.5, 2] } } as const; type Good = TupleToUnion<typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS>;',
parser: require.resolve('@typescript-eslint/parser'),
},
{
code: 'const COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Good = ValueOf<typeof COLORS>;',
parser: require.resolve('@typescript-eslint/parser'),
},
{
// eslint-disable-next-line max-len
code: 'const CONST = { AVATAR_SIZE: { SMALL: \'small\', MEDIUM: \'medium\', LARGE: \'large\' } } as const; type Good = { avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>; }',
parser: require.resolve('@typescript-eslint/parser'),
},
],
invalid: [
{
code: 'const STUFF = [\'a\', \'b\', \'c\'] as const; type Bad = (typeof STUFF)[number];',
errors: [{message: PREFER_TYPE_FEST_TUPLE_TO_UNION}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {TupleToUnion} from \'type-fest\';\nconst STUFF = [\'a\', \'b\', \'c\'] as const; type Bad = TupleToUnion<typeof STUFF>;',
},
{
code: 'const STUFF = [\'a\', \'b\', \'c\'] as const; type Bad = Record<string, (typeof STUFF)[number]>;',
errors: [{message: PREFER_TYPE_FEST_TUPLE_TO_UNION}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {TupleToUnion} from \'type-fest\';\nconst STUFF = [\'a\', \'b\', \'c\'] as const; type Bad = Record<string, TupleToUnion<typeof STUFF>>;',
},
{
code: 'const CONST = { VIDEO_PLAYER: { PLAYBACK_SPEEDS: [0.25, 0.5, 1, 1.5, 2] } } as const; type Bad = (typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS)[number];',
errors: [{message: PREFER_TYPE_FEST_TUPLE_TO_UNION}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {TupleToUnion} from \'type-fest\';\nconst CONST = { VIDEO_PLAYER: { PLAYBACK_SPEEDS: [0.25, 0.5, 1, 1.5, 2] } } as const; type Bad = TupleToUnion<typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS>;',
},
{
code: 'const TIMEZONES = [\'a\', \'b\'] as const; const test: Record<string, (typeof TIMEZONES)[number]> = { a: \'a\', b: \'b\' };',
errors: [{message: PREFER_TYPE_FEST_TUPLE_TO_UNION}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {TupleToUnion} from \'type-fest\';\nconst TIMEZONES = [\'a\', \'b\'] as const; const test: Record<string, TupleToUnion<typeof TIMEZONES>> = { a: \'a\', b: \'b\' };',
},
{
code: 'import type {Something} from \'type-fest\';\nconst TIMEZONES = [\'a\', \'b\'] as const; const test: Record<string, (typeof TIMEZONES)[number]> = { a: \'a\', b: \'b\' };',
errors: [{message: PREFER_TYPE_FEST_TUPLE_TO_UNION}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {Something, TupleToUnion} from \'type-fest\';\nconst TIMEZONES = [\'a\', \'b\'] as const; const test: Record<string, TupleToUnion<typeof TIMEZONES>> = { a: \'a\', b: \'b\' };',
},
{
code: 'const COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = (typeof COLORS)[keyof COLORS];',
errors: [{message: PREFER_TYPE_FEST_VALUE_OF}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {ValueOf} from \'type-fest\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = ValueOf<typeof COLORS>;',
},
{
// eslint-disable-next-line max-len
code: 'const CONST = { AVATAR_SIZE: { SMALL: \'small\', MEDIUM: \'medium\', LARGE: \'large\' } } as const; type Bad = { avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE]; }',
errors: [{message: PREFER_TYPE_FEST_VALUE_OF}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {ValueOf} from \'type-fest\';\nconst CONST = { AVATAR_SIZE: { SMALL: \'small\', MEDIUM: \'medium\', LARGE: \'large\' } } as const; type Bad = { avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>; }',
},
{
code: 'import type {ValueOf} from \'type-fest\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = (typeof COLORS)[keyof COLORS];',
errors: [{message: PREFER_TYPE_FEST_VALUE_OF}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {ValueOf} from \'type-fest\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = ValueOf<typeof COLORS>;',
},
{
code: 'import type {TupleToUnion} from \'type-fest\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = (typeof COLORS)[keyof COLORS];',
errors: [{message: PREFER_TYPE_FEST_VALUE_OF}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {TupleToUnion, ValueOf} from \'type-fest\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = ValueOf<typeof COLORS>;',
},
{
code: 'import somethingElse from \'something-else\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = (typeof COLORS)[keyof COLORS];',
errors: [{message: PREFER_TYPE_FEST_VALUE_OF}],
parser: require.resolve('@typescript-eslint/parser'),
output: 'import type {ValueOf} from \'type-fest\';\nimport somethingElse from \'something-else\';\nconst COLORS = { GREEN: \'green\', BLUE: \'blue\' } as const; type Bad = ValueOf<typeof COLORS>;',
},
],
});
41 changes: 41 additions & 0 deletions eslint-plugin-expensify/utils/imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Adds a named import to the import statement or creates a new import statement if it doesn't exist.
*
* @param {Object} context - The ESLint rule context.
* @param {Object} fixer - The ESLint fixer object.
* @param {ASTNode} importNode - The import statement node.
* @param {string} importName - The name of the import.
* @param {string} importPath - The path of the import.
* @param {boolean} [importAsType=false] - Whether the import should be treated as a type import.
* @returns {Array} An array of fixes to be applied by the fixer.
*/
function addNamedImport(context, fixer, importNode, importName, importPath, importAsType = false) {
const fixes = [];

if (importNode) {
const alreadyImported = importNode.specifiers.some(
specifier => specifier.imported.name === importName
);

if (!alreadyImported) {
const lastSpecifier = importNode.specifiers[importNode.specifiers.length - 1];

// Add ValueOf to existing type-fest import
fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${importName}`));
}
} else {
// Add import if it doesn't exist
fixes.push(
fixer.insertTextBefore(
context.getSourceCode().ast.body[0],
`import ${importAsType ? "type " : ""}{${importName}} from '${importPath}';\n`
)
);
}

return fixes;
}

module.exports = {
addNamedImport
};
Loading

0 comments on commit a8951a6

Please sign in to comment.