| name | lintcn |
| description | Type-aware TypeScript lint rules in .lintcn/ Go files. Only load this skill when creating, editing, or debugging rule files.
To just run the linter: `npx lintcn lint` (or `--fix`, `--tsconfig <path>`). Finds .lintcn/ by walking up from cwd. First build ~30s, cached ~1s. In monorepos, run from each package folder, not the root.
Warnings don't fail CI and only show for git-changed files by default. Use `--all-warnings` to see them across the entire codebase.
|
lintcn โ Writing Custom tsgolint Lint Rules
tsgolint rules are Go functions that listen for TypeScript AST nodes and use the
TypeScript type checker for type-aware analysis. Each rule lives in its own
subfolder under .lintcn/ and is compiled into a custom tsgolint binary.
Every rule MUST be in a subfolder โ flat .go files in .lintcn/ root are
not supported. The subfolder name = Go package name = rule identity.
Always run go build ./... inside .lintcn/ to validate rules compile.
Always run go test -v ./... inside .lintcn/ to run tests.
Directory Layout
Each rule is a subfolder. The Go package name must match the folder name:
.lintcn/
no_floating_promises/
no_floating_promises.go โ rule source (committed)
no_floating_promises_test.go โ tests (committed)
options.go โ rule options struct
await_thenable/
await_thenable.go
await_thenable_test.go
my_custom_rule/
my_custom_rule.go
.gitignore โ ignores generated Go files
go.mod โ generated
go.work โ generated
.tsgolint/ โ symlink to cached source (gitignored)
Adding Rules
npx lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises
npx lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go
npx lintcn list
npx lintcn remove no-floating-promises
npx lintcn lint
Rule Anatomy
Every rule is a rule.Rule struct with a Name and a Run function.
Run receives a RuleContext and returns a RuleListeners map โ a map from
ast.Kind to callback functions. The linter walks the AST and calls your
callback when it encounters a node of that kind.
package my_rule
import (
"github.com/microsoft/typescript-go/shim/ast"
"github.com/typescript-eslint/tsgolint/internal/rule"
)
var MyRule = rule.Rule{
Name: "my-rule",
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
return rule.RuleListeners{
ast.KindCallExpression: func(node *ast.Node) {
call := node.AsCallExpression()
ctx.ReportNode(node, rule.RuleMessage{
Id: "myError",
Description: "Something is wrong here.",
})
},
}
},
}
Metadata Comments
Add // lintcn: comments at the top for CLI metadata:
Available directives:
| Directive | Values | Default | Description |
|---|
lintcn:name | kebab-case | folder name | Rule display name |
lintcn:severity | error, warn | error | Severity level |
lintcn:description | text | empty | One-line description |
lintcn:source | URL | empty | Original source URL |
Warning Severity
Rules with // lintcn:severity warn:
- Don't fail CI (exit code 0)
- Only show for git-changed/untracked files โ unchanged files are skipped
- Use
--all-warnings to see warnings across the whole codebase
Warnings are for rules that guide agents writing new code without flooding
the output with violations from the rest of the codebase. Examples:
- "Remove
as any, the actual type is string"
- "This
|| fallback is unreachable, the left side is never nullish"
- "Unhandled Error return value, assign to a variable and check it"
Package Name
Each rule subfolder has its own Go package. The package name must match the
folder name (e.g. package no_floating_promises in folder no_floating_promises/).
The exported variable name must match the pattern var XxxRule = rule.Rule{...}.
RuleContext
ctx rule.RuleContext provides:
| Field | Type | Description |
|---|
SourceFile | *ast.SourceFile | Current file being linted |
Program | *compiler.Program | Full TypeScript program |
TypeChecker | *checker.Checker | TypeScript type checker |
ReportNode | func(node, msg) | Report error on a node |
ReportNodeWithFixes | func(node, msg, fixesFn) | Report with auto-fixes |
ReportNodeWithSuggestions | func(node, msg, suggFn) | Report with suggestions |
ReportRange | func(range, msg) | Report on a text range |
ReportDiagnostic | func(diagnostic) | Report with labeled ranges |
AST Node Listeners
Most Useful ast.Kind Values
ast.KindExpressionStatement
ast.KindReturnStatement
ast.KindThrowStatement
ast.KindIfStatement
ast.KindVariableDeclaration
ast.KindForInStatement
ast.KindCallExpression
ast.KindNewExpression
ast.KindBinaryExpression
ast.KindPropertyAccessExpression
ast.KindElementAccessExpression
ast.KindAwaitExpression
ast.KindConditionalExpression
ast.KindPrefixUnaryExpression
ast.KindTemplateExpression
ast.KindDeleteExpression
ast.KindVoidExpression
ast.KindFunctionDeclaration
ast.KindArrowFunction
ast.KindMethodDeclaration
ast.KindClassDeclaration
ast.KindEnumDeclaration
ast.KindUnionType
ast.KindIntersectionType
ast.KindAsExpression
Enter and Exit Listeners
By default, listeners fire when the AST walker enters a node.
Use rule.ListenerOnExit(kind) to fire when the walker exits โ useful
for scope tracking:
return rule.RuleListeners{
ast.KindFunctionDeclaration: func(node *ast.Node) {
currentScope = &scopeInfo{upper: currentScope}
},
rule.ListenerOnExit(ast.KindFunctionDeclaration): func(node *ast.Node) {
if !currentScope.hasAwait {
ctx.ReportNode(node, msg)
}
currentScope = currentScope.upper
},
}
Used by require_await, return_await, consistent_return, prefer_readonly for
tracking state across function bodies with a scope stack.
Allow/NotAllow Pattern Listeners
For destructuring and assignment contexts:
rule.ListenerOnAllowPattern(ast.KindObjectLiteralExpression)
rule.ListenerOnNotAllowPattern(ast.KindArrayLiteralExpression)
Used by no_unsafe_assignment and unbound_method.
Type Checker APIs
Getting Types
t := ctx.TypeChecker.GetTypeAtLocation(node)
t := utils.GetConstrainedTypeAtLocation(ctx.TypeChecker, node)
t := checker.Checker_getContextualType(ctx.TypeChecker, node, checker.ContextFlagsNone)
t := checker.Checker_getApparentType(ctx.TypeChecker, t)
t := checker.Checker_getAwaitedType(ctx.TypeChecker, t)
t := checker.Checker_getTypeFromTypeNode(ctx.TypeChecker, typeNode)
Type Flag Checks
TypeFlags are bitmasks โ check with utils.IsTypeFlagSet:
if utils.IsTypeFlagSet(t, checker.TypeFlagsVoid) { return }
if utils.IsTypeFlagSet(t, checker.TypeFlagsUndefined) { return }
if utils.IsTypeFlagSet(t, checker.TypeFlagsNever) { return }
if utils.IsTypeFlagSet(t, checker.TypeFlagsAny) { return }
if utils.IsTypeFlagSet(t, checker.TypeFlagsVoid|checker.TypeFlagsUndefined|checker.TypeFlagsNever) {
return
}
utils.IsTypeAnyType(t)
utils.IsTypeUnknownType(t)
utils.IsObjectType(t)
utils.IsTypeParameter(t)
Union and Intersection Types
Decomposing unions is the most common pattern โ 58 uses across all rules:
for _, part := range utils.UnionTypeParts(t) {
if utils.IsErrorLike(ctx.Program, ctx.TypeChecker, part) {
hasError = true
break
}
}
if utils.IsUnionType(t) { ... }
if utils.IsIntersectionType(t) { ... }
for _, part := range utils.IntersectionTypeParts(t) { ... }
result := utils.TypeRecurser(t, func(t *checker.Type) bool {
return utils.IsTypeAnyType(t)
})
Built-in Type Checks
utils.IsErrorLike(ctx.Program, ctx.TypeChecker, t)
utils.IsReadonlyErrorLike(ctx.Program, ctx.TypeChecker, t)
utils.IsPromiseLike(ctx.Program, ctx.TypeChecker, t)
utils.IsThenableType(ctx.TypeChecker, node, t)
checker.Checker_isArrayType(ctx.TypeChecker, t)
checker.IsTupleType(t)
checker.Checker_isArrayOrTupleType(ctx.TypeChecker, t)
utils.IsBuiltinSymbolLike(ctx.Program, ctx.TypeChecker, t, "Function")
utils.IsBuiltinSymbolLike(ctx.Program, ctx.TypeChecker, t, "RegExp")
utils.IsBuiltinSymbolLike(ctx.Program, ctx.TypeChecker, t, "ReadonlyArray")
Type Properties and Signatures
prop := checker.Checker_getPropertyOfType(ctx.TypeChecker, t, "then")
if prop != nil {
propType := ctx.TypeChecker.GetTypeOfSymbolAtLocation(prop, node)
}
props := checker.Checker_getPropertiesOfType(ctx.TypeChecker, t)
sigs := utils.GetCallSignatures(ctx.TypeChecker, t)
sigs := ctx.TypeChecker.GetCallSignatures(t)
params := checker.Signature_parameters(sig)
returnType := checker.Checker_getReturnTypeOfSignature(ctx.TypeChecker, sig)
typeArgs := checker.Checker_getTypeArguments(ctx.TypeChecker, t)
sig := checker.Checker_getResolvedSignature(ctx.TypeChecker, callNode)
Type Assignability
if checker.Checker_isTypeAssignableTo(ctx.TypeChecker, sourceType, targetType) {
}
constraint := checker.Checker_getBaseConstraintOfType(ctx.TypeChecker, t)
Symbols
symbol := ctx.TypeChecker.GetSymbolAtLocation(node)
decl := utils.GetDeclaration(ctx.TypeChecker, node)
t := checker.Checker_getTypeOfSymbol(ctx.TypeChecker, symbol)
t := checker.Checker_getDeclaredTypeOfSymbol(ctx.TypeChecker, symbol)
utils.IsSymbolFromDefaultLibrary(ctx.Program, symbol)
name, ok := checker.Checker_getAccessedPropertyName(ctx.TypeChecker, node)
Formatting Types for Error Messages
typeName := ctx.TypeChecker.TypeToString(t)
name := utils.GetTypeName(ctx.TypeChecker, t)
AST Navigation
Node Casting
Every AST node is *ast.Node. Use .AsXxx() to access specific fields:
call := node.AsCallExpression()
call.Expression
call.Arguments
binary := node.AsBinaryExpression()
binary.Left
binary.Right
binary.OperatorToken.Kind
prop := node.AsPropertyAccessExpression()
prop.Expression
prop.Name()
Type Predicates
ast.IsCallExpression(node)
ast.IsPropertyAccessExpression(node)
ast.IsIdentifier(node)
ast.IsAccessExpression(node)
ast.IsBinaryExpression(node)
ast.IsAssignmentExpression(node, includeCompound)
ast.IsVoidExpression(node)
ast.IsAwaitExpression(node)
ast.IsFunctionLike(node)
ast.IsArrowFunction(node)
ast.IsStringLiteral(node)
Skipping Parentheses
Always skip parentheses when analyzing expression content:
expression := ast.SkipParentheses(node.AsExpressionStatement().Expression)
Walking Parents
parent := node.Parent
for parent != nil {
if ast.IsCallExpression(parent) {
break
}
parent = parent.Parent
}
Reporting Errors
Simple Error
ctx.ReportNode(node, rule.RuleMessage{
Id: "myErrorId",
Description: "Something is wrong.",
Help: "Optional longer explanation.",
})
Error with Auto-Fix
Fixes are applied automatically by the linter:
ctx.ReportNodeWithFixes(node, msg, func() []rule.RuleFix {
return []rule.RuleFix{
rule.RuleFixInsertBefore(ctx.SourceFile, node, "await "),
}
})
Error with Suggestions
Suggestions require user confirmation:
ctx.ReportNodeWithSuggestions(node, msg, func() []rule.RuleSuggestion {
return []rule.RuleSuggestion{{
Message: rule.RuleMessage{Id: "addAwait", Description: "Add await"},
FixesArr: []rule.RuleFix{
rule.RuleFixInsertBefore(ctx.SourceFile, node, "await "),
},
}}
})
Error with Multiple Labeled Ranges
Highlight multiple code locations:
ctx.ReportDiagnostic(rule.RuleDiagnostic{
Range: exprRange,
Message: rule.RuleMessage{Id: "typeMismatch", Description: "Types are incompatible"},
LabeledRanges: []rule.RuleLabeledRange{
{Label: fmt.Sprintf("Type: %v", leftType), Range: leftRange},
{Label: fmt.Sprintf("Type: %v", rightType), Range: rightRange},
},
})
Fix Helpers
rule.RuleFixInsertBefore(ctx.SourceFile, node, "await ")
rule.RuleFixInsertAfter(node, ")")
rule.RuleFixReplace(ctx.SourceFile, node, "newCode")
rule.RuleFixRemove(ctx.SourceFile, node)
rule.RuleFixReplaceRange(textRange, "replacement")
rule.RuleFixRemoveRange(textRange)
Getting Token Ranges for Fixes
When you need the exact range of a keyword token (like void, as, await):
import "github.com/microsoft/typescript-go/shim/scanner"
voidTokenRange := scanner.GetRangeOfTokenAtPosition(ctx.SourceFile, node.Pos())
s := scanner.GetScannerForSourceFile(ctx.SourceFile, startPos)
tokenRange := s.TokenRange()
Rule Options
Rules can accept configuration via JSON:
var MyRule = rule.Rule{
Name: "my-rule",
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
opts := utils.UnmarshalOptions[MyRuleOptions](options, "my-rule")
},
}
type MyRuleOptions struct {
IgnoreVoid bool `json:"ignoreVoid"`
AllowedTypes []string `json:"allowedTypes"`
}
For lintcn rules, define the options struct directly in your rule file or
in a separate options.go file in the same subfolder.
State Tracking (Scope Stacks)
When you need to track state across function boundaries (like "does this
function contain an await?"), use enter/exit listener pairs with a linked
list as a stack:
type scopeInfo struct {
hasAwait bool
upper *scopeInfo
}
var currentScope *scopeInfo
enterFunc := func(node *ast.Node) {
currentScope = &scopeInfo{upper: currentScope}
}
exitFunc := func(node *ast.Node) {
if !currentScope.hasAwait {
ctx.ReportNode(node, msg)
}
currentScope = currentScope.upper
}
return rule.RuleListeners{
ast.KindFunctionDeclaration: enterFunc,
rule.ListenerOnExit(ast.KindFunctionDeclaration): exitFunc,
ast.KindArrowFunction: enterFunc,
rule.ListenerOnExit(ast.KindArrowFunction): exitFunc,
ast.KindAwaitExpression: func(node *ast.Node) {
currentScope.hasAwait = true
},
}
Testing
Tests use rule_tester.RunRuleTester which creates a TypeScript program from
inline code and runs the rule against it. The test file must use the same
package name as the rule:
package my_rule
import (
"testing"
"github.com/typescript-eslint/tsgolint/internal/rule_tester"
"github.com/typescript-eslint/tsgolint/internal/rules/fixtures"
)
func TestMyRule(t *testing.T) {
t.Parallel()
rule_tester.RunRuleTester(
fixtures.GetRootDir(),
"tsconfig.minimal.json",
t,
&MyRule,
validCases,
invalidCases,
)
}
Valid Test Cases (should NOT trigger)
var validCases = []rule_tester.ValidTestCase{
{Code: `const x = getUser("id");`},
{Code: `void dangerousCall();`},
{Code: `<div onClick={() => {}} />`, Tsx: true},
{Code: `import x from './foo'`, FileName: "index.ts"},
{Code: `getUser("id");`, Options: MyRuleOptions{IgnoreVoid: true}},
{
Code: `import { x } from './helper';`,
Files: map[string]string{
"helper.ts": `export const x = 1;`,
},
},
}
Invalid Test Cases (SHOULD trigger)
var invalidCases = []rule_tester.InvalidTestCase{
{
Code: `
declare function getUser(id: string): Error | { name: string };
getUser("id");
`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "noUnhandledError"},
},
},
{
Code: `getUser("id");`,
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "noUnhandledError", Line: 1, Column: 1, EndColumn: 15},
},
},
{
Code: `
declare const arr: number[];
delete arr[0];
`,
Errors: []rule_tester.InvalidTestCaseError{
{
MessageId: "noArrayDelete",
Suggestions: []rule_tester.InvalidTestCaseSuggestion{
{
MessageId: "useSplice",
Output: `
declare const arr: number[];
arr.splice(0, 1);
`,
},
},
},
},
},
{
Code: `const x = foo as any;`,
Output: []string{`const x = foo;`},
Errors: []rule_tester.InvalidTestCaseError{
{MessageId: "unsafeAssertion"},
},
},
}
Important Test Details
- MessageId must match the
Id field in your rule.RuleMessage
- Line/Column are 1-indexed, optional (omit for flexibility)
- Output is the code after ALL auto-fixes are applied (iterates up to 10 times)
- Suggestions check the output of each individual suggestion fix
- Tests run in parallel by default (
t.Parallel())
- Use
Only: true on a test case to run only that test (like .only in vitest)
- Use
Skip: true to skip a test case
Running Tests
cd .lintcn
go test -v ./...
go test -v -run TestMyRule
go test -count=1 ./...
Snapshots
Tests generate snapshot files with the full diagnostic output โ message text,
annotated source code, and underlined ranges. Run with UPDATE_SNAPS=true to
create or update them:
UPDATE_SNAPS=true go test -run TestMyRule -count=1 ./rules/my_rule/
Snapshots are written to internal/rule_tester/__snapshots__/{rule-name}.snap
inside the cached tsgolint source. Copy them into your rule folder for reference:
.lintcn/my_rule/__snapshots__/my-rule.snap
Always read the snapshot after writing tests โ it shows the exact messages
your rule produces, which is how you verify the output makes sense. Example
snapshot from no-type-assertion:
[TestNoTypeAssertion/invalid-7 - 1]
Diagnostic 1: typeAssertion (4:14 - 4:22)
Message: Type assertion `as User ({ name: string; age: number })`.
The expression type is `Error | User`. Try removing the assertion
or narrowing the type instead.
3 | declare const x: User | Error;
4 | const y = x as User;
| ~~~~~~~~~
5 |
---
[TestNoTypeAssertion/invalid-8 - 1]
Diagnostic 1: typeAssertion (4:14 - 4:24)
Message: Type assertion `as Config ({ host: string; port: number })`.
The expression type is `Config | null`. Try removing the assertion
or narrowing the type instead.
3 | declare const x: Config | null;
4 | const y = x as Config;
| ~~~~~~~~~~~
5 |
---
This shows: the message ID, position, full description text, and the source
code with the flagged range underlined. Use this to verify your error messages
are helpful and include enough type information for agents to act on.
Complete Rule Example: no-unhandled-error
A real rule that enforces the errore pattern โ errors when a call expression
returns a type containing Error and the result is discarded:
package no_unhandled_error
import (
"github.com/microsoft/typescript-go/shim/ast"
"github.com/microsoft/typescript-go/shim/checker"
"github.com/typescript-eslint/tsgolint/internal/rule"
"github.com/typescript-eslint/tsgolint/internal/utils"
)
var NoUnhandledErrorRule = rule.Rule{
Name: "no-unhandled-error",
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
return rule.RuleListeners{
ast.KindExpressionStatement: func(node *ast.Node) {
exprStatement := node.AsExpressionStatement()
expression := ast.SkipParentheses(exprStatement.Expression)
if ast.IsVoidExpression(expression) {
return
}
innerExpr := expression
if ast.IsAwaitExpression(innerExpr) {
innerExpr = ast.SkipParentheses(innerExpr.Expression())
}
if !ast.IsCallExpression(innerExpr) {
return
}
t := ctx.TypeChecker.GetTypeAtLocation(expression)
if utils.IsTypeFlagSet(t,
checker.TypeFlagsVoid|checker.TypeFlagsVoidLike|
checker.TypeFlagsUndefined|checker.TypeFlagsNever) {
return
}
for _, part := range utils.UnionTypeParts(t) {
if utils.IsErrorLike(ctx.Program, ctx.TypeChecker, part) {
ctx.ReportNode(node, rule.RuleMessage{
Id: "noUnhandledError",
Description: "Error-typed return value is not handled.",
})
return
}
}
},
}
},
}
Go Workspace Setup
.lintcn/ needs these generated files (created by lintcn add automatically):
go.mod โ module name MUST be a child path of tsgolint for internal/
package access:
module github.com/typescript-eslint/tsgolint/lintcn-rules
go 1.26
go.work โ workspace linking to cached tsgolint source:
go 1.26
use (
.
./.tsgolint
./.tsgolint/typescript-go
)
replace (
github.com/microsoft/typescript-go/shim/ast => ./.tsgolint/shim/ast
github.com/microsoft/typescript-go/shim/checker => ./.tsgolint/shim/checker
// ... all 14 shim modules
)
.tsgolint/ โ symlink to cached tsgolint clone (gitignored).
With this setup, gopls provides full autocomplete and go-to-definition on all
tsgolint and typescript-go APIs.