Writing TypeScript with AI
| View comments on Hacker NewsI’ve had a great experience using Cursor, so I wanted to share how I use it.
Here’s how I tend to use AI in my TypeScript projects. For reference, here are a few projects I’ve worked on with AI recently:
Background
Go over the type of projects I work on. Introduce what a few examples of what I’ve built with AI recently.
Approach
Explain what I do to use AI effectively
Larger Features
Having AIs plan out tasks is essential for larger chunks of work. This has become much easier in Cursor with the introduction of plan mode. Previously I would have the AI write a Markdown document explaining every piece of work it wanted to do. I’d then have it split that document into smaller chunks. Then I’d give it one file at a time until the entire feature was implemented.
I also have the AI identify what commands it might need to run, e.g. to lint, build, or run tests. I put this into the context when building the larger features so it can easily iterate and run the tools that it needs to make progress.
Examples:
- https://github.com/shepherdjerred/scout-for-lol/tree/080824b790e6f62e2579a727dc1611c3ad849e0f/ai_dev_log/competition/tasks
- https://github.com/shepherdjerred/scout-for-lol/blob/main/ai_dev_log/competition/DEVELOPMENT_CHEATSHEET.md
Guardrails
Cursor Rules
Cursors rules are very effective at steering the AI towards your style.
More examples:
Static Analysis
I’ve always been a fan of statically typed languages. There are so many kinds of bugs that a compiler can catch. With AI, statically typed languages get another benefit: both you and the AI can have a better understanding of the correctness of generated code.
In practice this means I make sure my tsc and eslint configs are strict. I enable linting with type information so the deeper analysis can be done.
tsconfig.json
{
"compilerOptions": {
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useUnknownInCatchVariables": true,
"noUncheckedSideEffectImports": true,
"noEmitOnError": true,
"forceConsistentCasingInFileNames": true
}
}
eslint.config.ts:
import const eslint: {
readonly meta: {
readonly name: string;
readonly version: string;
};
readonly configs: {
readonly recommended: {
readonly rules: Readonly<Linter.RulesRecord>;
};
readonly all: {
readonly rules: Readonly<Linter.RulesRecord>;
};
};
}
eslint from "@eslint/js";
import { function defineConfig(...args: ConfigWithExtendsArray): Config[]Helper function to define a config array.defineConfig } from "eslint/config";
import const tseslint: {
config: typeof config;
configs: {
all: CompatibleConfigArray;
base: CompatibleConfig;
disableTypeChecked: CompatibleConfig;
eslintRecommended: CompatibleConfig;
recommended: CompatibleConfigArray;
recommendedTypeChecked: CompatibleConfigArray;
recommendedTypeCheckedOnly: CompatibleConfigArray;
strict: CompatibleConfigArray;
strictTypeChecked: CompatibleConfigArray;
strictTypeCheckedOnly: CompatibleConfigArray;
stylistic: CompatibleConfigArray;
stylisticTypeChecked: CompatibleConfigArray;
stylisticTypeCheckedOnly: CompatibleConfigArray;
};
parser: CompatibleParser;
plugin: CompatiblePlugin;
}
tseslint from "typescript-eslint";
export default function defineConfig(...args: ConfigWithExtendsArray): Config[]Helper function to define a config array.defineConfig(
const eslint: {
readonly meta: {
readonly name: string;
readonly version: string;
};
readonly configs: {
readonly recommended: {
readonly rules: Readonly<Linter.RulesRecord>;
};
readonly all: {
readonly rules: Readonly<Linter.RulesRecord>;
};
};
}
eslint.configs: {
readonly recommended: {
readonly rules: Readonly<Linter.RulesRecord>;
};
readonly all: {
readonly rules: Readonly<Linter.RulesRecord>;
};
}
configs.recommended: {
readonly rules: Readonly<Linter.RulesRecord>;
}
recommended,
const tseslint: {
config: typeof config;
configs: {
all: CompatibleConfigArray;
base: CompatibleConfig;
disableTypeChecked: CompatibleConfig;
eslintRecommended: CompatibleConfig;
recommended: CompatibleConfigArray;
recommendedTypeChecked: CompatibleConfigArray;
recommendedTypeCheckedOnly: CompatibleConfigArray;
strict: CompatibleConfigArray;
strictTypeChecked: CompatibleConfigArray;
strictTypeCheckedOnly: CompatibleConfigArray;
stylistic: CompatibleConfigArray;
stylisticTypeChecked: CompatibleConfigArray;
stylisticTypeCheckedOnly: CompatibleConfigArray;
};
parser: CompatibleParser;
plugin: CompatiblePlugin;
}
tseslint.configs: {
all: CompatibleConfigArray;
base: CompatibleConfig;
disableTypeChecked: CompatibleConfig;
eslintRecommended: CompatibleConfig;
recommended: CompatibleConfigArray;
recommendedTypeChecked: CompatibleConfigArray;
recommendedTypeCheckedOnly: CompatibleConfigArray;
... 5 more ...;
stylisticTypeCheckedOnly: CompatibleConfigArray;
}
configs.strictTypeChecked: CompatibleConfigArrayContains all of `recommended`, `recommended-type-checked`, and `strict`, along with additional strict rules that require type information.strictTypeChecked,
const tseslint: {
config: typeof config;
configs: {
all: CompatibleConfigArray;
base: CompatibleConfig;
disableTypeChecked: CompatibleConfig;
eslintRecommended: CompatibleConfig;
recommended: CompatibleConfigArray;
recommendedTypeChecked: CompatibleConfigArray;
recommendedTypeCheckedOnly: CompatibleConfigArray;
strict: CompatibleConfigArray;
strictTypeChecked: CompatibleConfigArray;
strictTypeCheckedOnly: CompatibleConfigArray;
stylistic: CompatibleConfigArray;
stylisticTypeChecked: CompatibleConfigArray;
stylisticTypeCheckedOnly: CompatibleConfigArray;
};
parser: CompatibleParser;
plugin: CompatiblePlugin;
}
tseslint.configs: {
all: CompatibleConfigArray;
base: CompatibleConfig;
disableTypeChecked: CompatibleConfig;
eslintRecommended: CompatibleConfig;
recommended: CompatibleConfigArray;
recommendedTypeChecked: CompatibleConfigArray;
recommendedTypeCheckedOnly: CompatibleConfigArray;
... 5 more ...;
stylisticTypeCheckedOnly: CompatibleConfigArray;
}
configs.stylisticTypeChecked: CompatibleConfigArrayContains all of `stylistic`, along with additional stylistic rules that require type information.stylisticTypeChecked,
{
ConfigObject<RulesConfig>.languageOptions?: LanguageOptions | undefinedAn object containing settings related to how the language is configured for
linting.languageOptions: {
parserOptions: {
projectService: boolean;
}
parserOptions: {
projectService: booleanprojectService: true,
},
},
},
);
Custom Eslint Rules
In addition to enable the strict default config for eslint, I also write plenty of eslint rules. This is incredibly easy with AI. I can use eslint as an automatic feedback mechanism to force the AI to take a particular approach, avoid certain behaviors, etc.
As an example, I really dislike doing deep nested checks in TypeScript. I much rather use Zod. I have a rule to prevent AI from doing these kinds of checks.
Example:
const const a: unknowna: unknown = {
x: {
y: string;
}
x: {
y: stringy: "some_value",
},
};
// I dislike this
if (
typeof const a: unknowna === "object" &&
const a: object | nulla !== null &&
"x" in const a: objecta &&
typeof const a: object & Record<"x", unknown>a.x: unknownx === "object" &&
const a: object & Record<"x", unknown>a.x: object | nullx !== null &&
"y" in const a: object & Record<"x", unknown>a.x: objectx &&
typeof const a: object & Record<"x", unknown>a.x: object & Record<"y", unknown>x.y: unknowny === "string"
) {
var console: ConsoleThe `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.log(message?: any, ...optionalParams: any[]): void (+3 overloads)Prints to `stdout` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
```
See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.log(const a: object & Record<"x", unknown>a.x: object & Record<"y", unknown>x.y: stringy); // Finally can access it safely
}
// I prefer this
import { import zz } from "zod";
const const schema: z.ZodObject<{
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>;
}, "strip", z.ZodTypeAny, {
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
schema = import zz.object<{
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>;
}>(shape: {
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>;
}, params?: z.RawCreateParams): z.ZodObject<{
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>;
}, "strip", z.ZodTypeAny, {
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
export object
object({
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>
x: import zz.object<{
y: z.ZodString;
}>(shape: {
y: z.ZodString;
}, params?: z.RawCreateParams): z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>
export object
object({
y: z.ZodStringy: import zz.function string(params?: z.RawCreateParams & {
coerce?: true;
}): z.ZodString
export string
string(),
}),
});
const const result: z.SafeParseReturnType<{
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
result = const schema: z.ZodObject<{
x: z.ZodObject<{
y: z.ZodString;
}, "strip", z.ZodTypeAny, {
y: string;
}, {
y: string;
}>;
}, "strip", z.ZodTypeAny, {
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
schema.ZodType<{ x: { y: string; }; }, ZodObjectDef<{ x: ZodObject<{ y: ZodString; }, "strip", ZodTypeAny, { y: string; }, { y: string; }>; }, "strip", ZodTypeAny>, { ...; }>.safeParse(data: unknown, params?: z.util.InexactPartial<z.ParseParams>): z.SafeParseReturnType<{
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
safeParse(const a: unknowna);
if (const result: z.SafeParseReturnType<{
x: {
y: string;
};
}, {
x: {
y: string;
};
}>
result.success: booleansuccess) {
var console: ConsoleThe `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.log(message?: any, ...optionalParams: any[]): void (+3 overloads)Prints to `stdout` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
```
See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.log(const result: z.SafeParseSuccess<{
x: {
y: string;
};
}>
result.data: {
x: {
y: string;
};
}
data.x: {
y: string;
}
x.y: stringy);
} else {
// didn't match
}
Here’s an example of this rule in my eslint.config.ts:
TODO: we probably need a complete example here
{
selector: "UnaryExpression[operator='typeof']:not([argument.name='Bun'])", message: "Prefer Zod schema validation over typeof operator. Use z.string(), z.number(), etc. instead.",}
You can also write more complicated eslint rules. For example I noticed the AI occasionally using Zod to typecheck when the types already matched. AI can write these custom rules very effectively, too.
TODO: collapse this
import {
enum AST_NODE_TYPESAST_NODE_TYPES,
import ESLintUtilsESLintUtils,
type import TSESTreeTSESTree,
} from "@typescript-eslint/utils";
const const createRule: <Options extends readonly unknown[], MessageIds extends string>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, unknown>>) => RuleModuleWithName<MessageIds, Options, unknown, ESLintUtils.RuleListener>createRule = import ESLintUtilsESLintUtils.function RuleCreator<unknown>(urlCreator: (ruleName: string) => string): <Options, MessageIds>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, unknown>>) => RuleModuleWithName<MessageIds, Options, unknown, ESLintUtils.RuleListener>Creates reusable function to create rules with default options and docs URLs.RuleCreator(
(name: stringname) =>
`https://github.com/shepherdjerred/homelab/blob/main/eslint-rules/${name: stringname}.ts`,
);
export const const noRedundantZodParse: RuleModuleWithName<"redundantParse", [], unknown, ESLintUtils.RuleListener>noRedundantZodParse = const createRule: <[], "redundantParse">({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<[], "redundantParse", unknown>>) => RuleModuleWithName<"redundantParse", [], unknown, ESLintUtils.RuleListener>createRule({
name: stringname: "no-redundant-zod-parse",
meta: ESLintUtils.NamedCreateRuleMeta<"redundantParse", unknown, []>meta: {
type: "problem" | "layout" | "suggestion"The type of rule.
- `"problem"` means the rule is identifying code that either will cause an error or may cause a confusing behavior. Developers should consider this a high priority to resolve.
- `"suggestion"` means the rule is identifying something that could be done in a better way but no errors will occur if the code isn’t changed.
- `"layout"` means the rule cares primarily about whitespace, semicolons, commas, and parentheses, all the parts of the program that determine how the code looks rather than how it executes. These rules work on parts of the code that aren’t specified in the AST.type: "problem",
docs: RuleMetaDataDocsdocs: {
RuleMetaDataDocs.description: stringConcise description of the rule.description:
"Disallow parsing values with Zod when the type is already known and matches the schema output",
},
messages: Record<"redundantParse", string>A map of messages which the rule can report.
The key is the messageId, and the string is the parameterised error string.
See: https://eslint.org/docs/developer-guide/working-with-rules#messageidsmessages: {
redundantParse: stringredundantParse:
"Redundant Zod parse: the value '{{valueName}}' already has type '{{valueType}}' which matches the schema output. Zod validation is for unknown/untrusted data.",
},
schema: JSONSchema4 | readonly JSONSchema4[]The options schema. Supply an empty array if there are no options.schema: [],
hasSuggestions?: boolean | undefinedSpecifies whether rules can return suggestions. Omit if there is no suggestionshasSuggestions: false,
},
defaultOptions?: readonly [] | undefineddefaultOptions: [],
create: (context: Readonly<RuleContext<"redundantParse", []>>, optionsWithDefault: readonly []) => ESLintUtils.RuleListenercreate(context: Readonly<RuleContext<"redundantParse", []>>context) {
const const services: ParserServicesWithTypeInformationservices = import ESLintUtilsESLintUtils.function getParserServices<"redundantParse", []>(context: Readonly<RuleContext<"redundantParse", []>>): ParserServicesWithTypeInformation (+3 overloads)Try to retrieve type-aware parser service from context.
This **_will_** throw if it is not available.getParserServices(context: Readonly<RuleContext<"redundantParse", []>>context);
const const checker: TypeCheckerchecker = const services: ParserServicesWithTypeInformationservices.ParserServicesWithTypeInformation.program: Programprogram.Program.getTypeChecker(): TypeCheckerGets a type checker that can be used to semantically analyze source files in the program.getTypeChecker();
function function (local function) isZodSchema(node: TSESTree.Node): booleanisZodSchema(node: TSESTree.Nodenode: import TSESTreeTSESTree.type Node = TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionNode): boolean {
try {
const const tsNode: TSESTreeToTSNode<TSESTree.Node>tsNode = const services: ParserServicesWithTypeInformationservices.ParserServicesNodeMaps.esTreeNodeToTSNodeMap: ParserWeakMapESTreeToTSNode<TSESTree.Node>esTreeNodeToTSNodeMap.ParserWeakMapESTreeToTSNode<Node>.get<TSESTree.Node>(key: TSESTree.Node): TSESTreeToTSNode<TSESTree.Node>get(node: TSESTree.Nodenode);
const const type: Typetype = const checker: TypeCheckerchecker.TypeChecker.getTypeAtLocation(node: Node): TypegetTypeAtLocation(const tsNode: TSESTreeToTSNode<TSESTree.Node>tsNode);
const const typeString: stringtypeString = const checker: TypeCheckerchecker.TypeChecker.typeToString(type: Type, enclosingDeclaration?: Node, flags?: TypeFormatFlags): stringtypeToString(const type: Typetype);
return (
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("Zod") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodType") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodString") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodNumber") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodBoolean") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodArray") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodObject") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodRecord") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodUnion") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodLazy") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodLiteral") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodEnum") ||
const typeString: stringtypeString.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes("ZodNativeEnum")
);
} catch {
return false;
}
}
function function (local function) getValueName(node: TSESTree.Node): stringgetValueName(node: TSESTree.Nodenode: import TSESTreeTSESTree.type Node = TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionNode): string {
if (node: TSESTree.Nodenode.type: AST_NODE_TYPEStype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.Identifier = "Identifier"Identifier) {
return node: TSESTree.Identifiernode.Identifier.name: stringname;
}
return context: Readonly<RuleContext<"redundantParse", []>>context.sourceCode: Readonly<SourceCode>A SourceCode object that you can use to work with the source that
was passed to ESLint.sourceCode.function getText(node?: TSESTree.Node | TSESTree.Token, beforeCount?: number, afterCount?: number): stringGets the source code for the given node.getText(node: TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 152 more ... | TSESTree.YieldExpressionnode);
}
function function (local function) isUnknownOrAny(typeString: string): booleanisUnknownOrAny(typeString: stringtypeString: string): boolean {
return typeString: stringtypeString === "unknown" || typeString: stringtypeString === "any";
}
function function (local function) isInSafeParseConditional(parseCallNode: TSESTree.CallExpression, schemaNode: TSESTree.Node, argument: TSESTree.Node): booleanisInSafeParseConditional(
parseCallNode: TSESTree.CallExpressionparseCallNode: import TSESTreeTSESTree.CallExpression,
schemaNode: TSESTree.NodeschemaNode: import TSESTreeTSESTree.type Node = TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionNode,
argument: TSESTree.Nodeargument: import TSESTreeTSESTree.type Node = TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionNode,
): boolean {
// Check if this parse call is inside a conditional that checks safeParse().success
// Pattern: safeParse(x).success ? [parse(x)] : []
let let parent: TSESTree.Node | undefinedparent: import TSESTreeTSESTree.type Node = TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionNode | undefined = parseCallNode: TSESTree.CallExpressionparseCallNode.BaseNode.parent: TSESTree.Nodeparent;
// Look up the tree to find a ConditionalExpression
while (let parent: TSESTree.Node | undefinedparent !== var undefinedundefined) {
if (let parent: TSESTree.Nodeparent.type: AST_NODE_TYPEStype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.ConditionalExpression = "ConditionalExpression"ConditionalExpression) {
const const test: TSESTree.Expressiontest = let parent: TSESTree.ConditionalExpressionparent.ConditionalExpression.test: TSESTree.Expressiontest;
// Check if the test is a .success member access
if (
const test: TSESTree.Expressiontest.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 18 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.MemberExpression = "MemberExpression"MemberExpression &&
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.property: TSESTree.PrivateIdentifier | TSESTree.Expressionproperty.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 19 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.Identifier = "Identifier"Identifier &&
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.property: TSESTree.Identifierproperty.Identifier.name: stringname === "success"
) {
// Check if the object is a safeParse call
if (
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.Expressionobject.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 18 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.CallExpression = "CallExpression"CallExpression &&
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.CallExpressionobject.CallExpression.callee: TSESTree.Expressioncallee.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 18 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.MemberExpression = "MemberExpression"MemberExpression &&
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.CallExpressionobject.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.PrivateIdentifier | TSESTree.Expressionproperty.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 19 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.Identifier = "Identifier"Identifier &&
const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.CallExpressionobject.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.Identifierproperty.Identifier.name: stringname === "safeParse"
) {
// Check if it's the same schema
const const safeParseSchema: TSESTree.ExpressionsafeParseSchema = const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.CallExpressionobject.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.MemberExpressionBase.object: TSESTree.Expressionobject;
const const safeParseArg: TSESTree.CallExpressionArgumentsafeParseArg = const test: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNametest.MemberExpressionBase.object: TSESTree.CallExpressionobject.CallExpression.arguments: TSESTree.CallExpressionArgument[]arguments[0];
// Compare schema and argument
const const isSameSchema: booleanisSameSchema =
context: Readonly<RuleContext<"redundantParse", []>>context.sourceCode: Readonly<SourceCode>A SourceCode object that you can use to work with the source that
was passed to ESLint.sourceCode.function getText(node?: TSESTree.Node | TSESTree.Token, beforeCount?: number, afterCount?: number): stringGets the source code for the given node.getText(const safeParseSchema: TSESTree.ExpressionsafeParseSchema) ===
context: Readonly<RuleContext<"redundantParse", []>>context.sourceCode: Readonly<SourceCode>A SourceCode object that you can use to work with the source that
was passed to ESLint.sourceCode.function getText(node?: TSESTree.Node | TSESTree.Token, beforeCount?: number, afterCount?: number): stringGets the source code for the given node.getText(schemaNode: TSESTree.NodeschemaNode);
const const isSameArg: booleanisSameArg =
const safeParseArg: TSESTree.CallExpressionArgumentsafeParseArg !== var undefinedundefined &&
context: Readonly<RuleContext<"redundantParse", []>>context.sourceCode: Readonly<SourceCode>A SourceCode object that you can use to work with the source that
was passed to ESLint.sourceCode.function getText(node?: TSESTree.Node | TSESTree.Token, beforeCount?: number, afterCount?: number): stringGets the source code for the given node.getText(const safeParseArg: TSESTree.CallExpressionArgumentsafeParseArg) ===
context: Readonly<RuleContext<"redundantParse", []>>context.sourceCode: Readonly<SourceCode>A SourceCode object that you can use to work with the source that
was passed to ESLint.sourceCode.function getText(node?: TSESTree.Node | TSESTree.Token, beforeCount?: number, afterCount?: number): stringGets the source code for the given node.getText(argument: TSESTree.Nodeargument);
if (const isSameSchema: booleanisSameSchema && const isSameArg: booleanisSameArg) {
return true;
}
}
}
}
let parent: TSESTree.Node | undefinedparent = let parent: TSESTree.CallExpression | TSESTree.AccessorProperty | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.BinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | TSESTree.CatchClause | TSESTree.ChainExpression | TSESTree.ClassBody | ... 153 more ... | TSESTree.YieldExpressionparent.parent?: TSESTree.CallExpression | TSESTree.AccessorPropertyComputedName | TSESTree.AccessorPropertyNonComputedName | TSESTree.ArrayExpression | TSESTree.ArrayPattern | TSESTree.ArrowFunctionExpression | TSESTree.AssignmentExpression | TSESTree.AssignmentPattern | TSESTree.AwaitExpression | TSESTree.PrivateInExpression | TSESTree.SymmetricBinaryExpression | TSESTree.BlockStatement | TSESTree.BreakStatement | ... 196 more ... | undefinedparent;
}
return false;
}
return {
RuleListenerBaseSelectors.CallExpression?: RuleFunction<TSESTree.CallExpression> | undefinedCallExpression(node: TSESTree.CallExpressionnode) {
// Check if this is a .parse() or .safeParse() call
if (
node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.Expressioncallee.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 18 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.MemberExpression = "MemberExpression"MemberExpression &&
node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.PrivateIdentifier | TSESTree.Expressionproperty.type: AST_NODE_TYPES.ArrayExpression | AST_NODE_TYPES.ArrayPattern | AST_NODE_TYPES.ArrowFunctionExpression | AST_NODE_TYPES.AssignmentExpression | AST_NODE_TYPES.AwaitExpression | AST_NODE_TYPES.BinaryExpression | AST_NODE_TYPES.CallExpression | AST_NODE_TYPES.ChainExpression | AST_NODE_TYPES.ClassExpression | AST_NODE_TYPES.ConditionalExpression | AST_NODE_TYPES.FunctionExpression | AST_NODE_TYPES.Identifier | AST_NODE_TYPES.ImportExpression | AST_NODE_TYPES.JSXElement | AST_NODE_TYPES.JSXFragment | AST_NODE_TYPES.Literal | ... 19 more ... | AST_NODE_TYPES.TSTypeAssertiontype === enum AST_NODE_TYPESAST_NODE_TYPES.function (enum member) AST_NODE_TYPES.Identifier = "Identifier"Identifier &&
(node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.Identifierproperty.Identifier.name: stringname === "parse" ||
node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.Identifierproperty.Identifier.name: stringname === "safeParse") &&
node: TSESTree.CallExpressionnode.CallExpression.arguments: TSESTree.CallExpressionArgument[]arguments.Array<CallExpressionArgument>.length: numberGets or sets the length of the array. This is a number one higher than the highest index in the array.length > 0
) {
const const schemaNode: TSESTree.ExpressionschemaNode = node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.MemberExpressionBase.object: TSESTree.Expressionobject;
const const argument: TSESTree.CallExpressionArgumentargument = node: TSESTree.CallExpressionnode.CallExpression.arguments: TSESTree.CallExpressionArgument[]arguments[0];
// TypeScript guard - argument should exist due to length check above
if (!const argument: TSESTree.CallExpressionArgumentargument) {
return;
}
// Check if the callee is a Zod schema
if (!function (local function) isZodSchema(node: TSESTree.Node): booleanisZodSchema(const schemaNode: TSESTree.ExpressionschemaNode)) {
return;
}
// Skip if this is only a safeParse call (we only want to check parse calls)
if (node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.Identifierproperty.Identifier.name: "parse" | "safeParse"name === "safeParse") {
return;
}
try {
// Get the input argument's type
const const argTsNode: TSESTreeToTSNode<TSESTree.CallExpressionArgument>argTsNode = const services: ParserServicesWithTypeInformationservices.ParserServicesNodeMaps.esTreeNodeToTSNodeMap: ParserWeakMapESTreeToTSNode<TSESTree.Node>esTreeNodeToTSNodeMap.ParserWeakMapESTreeToTSNode<Node>.get<TSESTree.CallExpressionArgument>(key: TSESTree.CallExpressionArgument): TSESTreeToTSNode<TSESTree.CallExpressionArgument>get(const argument: TSESTree.CallExpressionArgumentargument);
const const argType: TypeargType = const checker: TypeCheckerchecker.TypeChecker.getTypeAtLocation(node: Node): TypegetTypeAtLocation(const argTsNode: TSESTreeToTSNode<TSESTree.CallExpressionArgument>argTsNode);
const const argTypeString: stringargTypeString = const checker: TypeCheckerchecker.TypeChecker.typeToString(type: Type, enclosingDeclaration?: Node, flags?: TypeFormatFlags): stringtypeToString(const argType: TypeargType);
// Skip if the argument is unknown or any - those SHOULD be validated
if (function (local function) isUnknownOrAny(typeString: string): booleanisUnknownOrAny(const argTypeString: stringargTypeString)) {
return;
}
// Get the return type of the entire parse() call expression
const const parseCallTsNode: CallExpressionparseCallTsNode = const services: ParserServicesWithTypeInformationservices.ParserServicesNodeMaps.esTreeNodeToTSNodeMap: ParserWeakMapESTreeToTSNode<TSESTree.Node>esTreeNodeToTSNodeMap.ParserWeakMapESTreeToTSNode<Node>.get<TSESTree.CallExpression>(key: TSESTree.CallExpression): CallExpressionget(node: TSESTree.CallExpressionnode);
const const parseReturnType: TypeparseReturnType = const checker: TypeCheckerchecker.TypeChecker.getTypeAtLocation(node: Node): TypegetTypeAtLocation(const parseCallTsNode: CallExpressionparseCallTsNode);
const const parseReturnTypeString: stringparseReturnTypeString = const checker: TypeCheckerchecker.TypeChecker.typeToString(type: Type, enclosingDeclaration?: Node, flags?: TypeFormatFlags): stringtypeToString(const parseReturnType: TypeparseReturnType);
// If the argument type already matches the parse return type, it's redundant
// This works for both primitive types and branded types
if (const argTypeString: stringargTypeString === const parseReturnTypeString: stringparseReturnTypeString) {
// Don't flag if this parse is part of a safeParse check pattern
// Pattern: safeParse(x).success ? [parse(x)] : []
if (function (local function) isInSafeParseConditional(parseCallNode: TSESTree.CallExpression, schemaNode: TSESTree.Node, argument: TSESTree.Node): booleanisInSafeParseConditional(node: TSESTree.CallExpressionnode, const schemaNode: TSESTree.ExpressionschemaNode, const argument: TSESTree.CallExpressionArgumentargument)) {
return;
}
context: Readonly<RuleContext<"redundantParse", []>>context.function report(descriptor: ReportDescriptor<"redundantParse">): voidReports a problem in the code.report({
ReportDescriptorNodeOptionalLoc.node: TSESTree.Node | TSESTree.TokenThe Node or AST Token which the report is being attached tonode: node: TSESTree.CallExpressionnode.CallExpression.callee: TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedNamecallee.property: TSESTree.Identifierproperty,
messageId: "redundantParse"messageId: "redundantParse",
ReportDescriptorBase<MessageIds extends string>.data?: Readonly<Record<string, unknown>> | undefinedThe parameters for the message string associated with `messageId`.data: {
valueName: stringvalueName: function (local function) getValueName(node: TSESTree.Node): stringgetValueName(const argument: TSESTree.CallExpressionArgumentargument),
valueType: stringvalueType: const argTypeString: stringargTypeString,
},
});
}
} catch {
// If we can't analyze the types, don't report
return;
}
}
},
};
},
});
More examples:
- https://github.com/shepherdjerred/scout-for-lol/blob/080824b790e6f62e2579a727dc1611c3ad849e0f/eslint.config.ts
- https://github.com/shepherdjerred/scout-for-lol/tree/080824b790e6f62e2579a727dc1611c3ad849e0f/eslint-rules