Skip to content

ArnaudBarre/tsl

Repository files navigation

tsl npm

tsl is an extension of tsc for type-aware linting, using customizable rules to generate reports that are displayed alongside built-in type errors. It's designed to be used in conjunction with fast linters like Oxlint or Biome that can't leverage type information and currently lack APIs for writing custom rules in TypeScript.

Features

What is type-aware linting?

Most lint rules only rely on the structure (AST) to detect issues. On top of that, type-aware linting leverage type information, such as the types of variables or return values, to detect new issues. Here are two of the most common issues that type-aware linting can detect:

async function foo() {
  try {
    doAsyncWork(); // <- Missing await
    // ^ core/noFloatingPromises: Promises must be awaited, [...]
  } catch {
    // This will never run, even if doAsyncWork() throws
    return getDefault();
  }
}
type User = { firstName: string | null };

function greet(user: User) {
  console.log(`Hello ${user.firstName}`);
  //                   ^ core/restrictTemplateExpressions: Invalid type "string | null" of template literal expression.
}

greet({ firstName: null }); // logs "Hello null"

To know if you are using type-aware linting in ESLint, see if @typescript-eslint/parser is installed and if you have project or projectService in languageOptions.parserOptions in your config.

Installation

bun add -D tsl

Note

TS 5.8 is expected as a peer dependency.

Add a configuration

Add a tsl.config.ts file to your project root. If you don't have one, all core rules are enabled (cli only, editor integration requires a config).

You can either enable all core rules and disable some of them or update options if needed,

// tsl.config.ts
import { core, defineConfig } from "tsl";

export default defineConfig({
  rules: [
    ...core.all(),
    core.strictBooleanExpressions("off"),
    core.switchExhaustivenessCheck({
      considerDefaultExhaustiveForUnions: true,
    }),
  ],
});

or pick only the rules you want to enable.

// tsl.config.ts
import { core, defineConfig } from "tsl";

export default defineConfig({
  rules: [
    core.noFloatingPromises(),
    core.noForInArray(),
    core.preferOptionalChain(),
    core.switchExhaustivenessCheck({
      considerDefaultExhaustiveForUnions: true,
    }),
  ],
});

Migrate from typescript-eslint

If you are using typescript-eslint, you can import the rules supported by tsl with this command:

bunx tsl/migrate

Add the TypeScript plugin

Instead of developing multiple plugins for each IDE, tsl provides a compiler plugin that can be used to display diagnostics in the editor and provide suggestions.

In your tsconfig.json add the following:

{
  "compilerOptions": {
    "plugins": [{ "name": "tsl/plugin" }]
  }
}

Important

If you use VS Code, you need to run the "TypeScript: Select TypeScript Version" command (when viewing a TS file) and choose "Use Workspace Version".

Add the patches

Rules like prefer-promise-reject-errors and use-unknown-in-catch-callback-variable in typescript-eslint are implemented with patches to override the builtin types. To do so, add the following to your tsconfig.json:

{
  "compilerOptions": {
    "types": ["tsl/patches"]
  }
}

They can also be added individually with tsl/patches/promiseRejectError or tsl/patches/unknowninCatchCallbacks.

Update your scripts to run the linter

To avoid parsing and typechecking twice your codebase, tsl runs first tsc via the Node API and then runs the rules on the TS AST.

{
  "scripts": {
-    "typecheck": "tsc"
+    "typecheck": "tsl"
  }
}

Can I use it without the tsc wrapper?

Yes, you can run tsl --lint-only directly. But if you use rules that request type information for a lot of nodes this can be very inefficient. As an example, running on my work codebase (~1k TS files), tsc takes 17s, tsl --lint-only takes 17s, and tsl (doing both) takes 21s.

Update your CI

-  - run: bun tsc
+  - run: bun tsl

Run for another tsconfig

tsl -p tsconfig.test.json
tsl --project tsconfig.test.json

Configuration

ignore

Everything in node_modules is ignored by default. If you want to ignore generated code, you can use the ignore option. The current implementation is simply ignore.some((i) => path.includes(i)). By design, files that are excluded in the TS config will not be linted.

defineConfig({
  ignore: ["src/generated/"],
});

diagnosticCategory

To differentiate tsl reports from TS errors, tsl diagnostics are reported by default as warnings. If you prefer having only red squiggles, you can set the diagnosticCategory option to "error".

defineConfig({
  diagnosticCategory: "error",
});

overrides

If for some subset of files you want to enable or disable rules, you can use the overrides option like this:

defineConfig({
  overrides: [
    {
      files: [".server.ts"],
      rules: [
        core.dotNotation("off"),
        core.switchExhaustivenessCheck({ requireDefaultForNonUnion: true }),
      ],
    },
  ],
});

Like the ignore option, the files option test for inclusion against the file path.

Redeclared rules (identical name) completely replace the "base" rule, there is no merging of options.

Ignore comments

Rules reports can be ignored with line comments (ignore next line) or one line block comments (ignore for the whole file).

/* tsl-ignore */
/* tsl-ignore org/ruleA */

// tsl-ignore org/ruleB
const a = 1;

// tsl-ignore org/ruleC, org/ruleD: reason
const b = 2;

Custom rules

Writing custom rules is part of the core value of tsl.

Rules run on the TS AST, which is less known than ESTree but allows to query type information for a given node with context.checker.getTypeAtLocation(node). Use ast-explorer.dev to explore the AST. To explore type information, use ts-ast-viewer.

By default, the TS AST is, funny enough, poorly typed. That's why tsl ships with rewritten AST types that allows for type narrowing and exhaustive switches.

To help build rules, a few common utils are available on context.utils, and context.checker is overridden with some type changes. If you need to pass the checker to another library that expects the builtin TypeChecker type, like ts-api-utils, you can use context.rawChecker.

import { type AST, core, defineConfig } from "tsl";
import { SyntaxKind, TypeFlags } from "typescript";

export default defineConfig({
  rules: [
    ...core.all(),
    {
      name: "org/useLogger",
      visitor: {
        CallExpression(context, node) {
          if (
            node.expression.kind === SyntaxKind.PropertyAccessExpression
            && node.expression.expression.kind === SyntaxKind.Identifier
            && node.expression.expression.text === "console"
          ) {
            node.expression.expression satisfies AST.Identifier;
            context.report({ node, message: "Use logger instead" });
          }
        },
      },
    },
    {
      name: "org/jsxNoNumberTruthiness",
      visitor: {
        BinaryExpression(context, node) {
          if (
            node.parent.kind === SyntaxKind.JsxExpression
            && node.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken
          ) {
            const type = context.checker.getTypeAtLocation(node.left);
            if (context.utils.typeOrUnionHasFlag(type, TypeFlags.NumberLike)) {
              context.report({
                node,
                message:
                  "Don't use logical expression on a number inside JSX, you might render the character 0 instead of rendering nothing.",
              });
            }
          }
        },
      },
    },
  ],
});

Core rules

Currently, the list of core rules are the type-aware lint rules I use from typescript-eslint. If you think more rules should be added, please open an issue, but to reduce the surface, only non-styling type-aware rules will be accepted. Here is the list of typescript-eslint type aware rules with their status:

  • await-thenable: βœ… Implemented
  • consistent-return: πŸ›‘ Implementation not planned, you can use noImplicitReturns compilerOption
  • consistent-type-exports: πŸ›‘ Implementation not planned, you can use verbatimModuleSyntax compilerOption
  • dot-notation: βœ… Implemented without options
  • naming-convention: Styling is out of core
  • no-array-delete: βœ… Implemented
  • no-base-to-string: βœ… Implemented, only String(), .to(Locale)String() and .join() are checked, see restrict-plus-operands and restrict-template-expressions for other checks
  • no-confusing-void-expression: βœ… Implemented
  • no-deprecated: ❌ Not implemented
  • no-duplicate-type-constituents: πŸ—‘οΈ Merged with no-redundant-type-constituents
  • no-floating-promises: βœ… Implemented, allowList is named based only
  • no-for-in-array: βœ… Implemented
  • no-implied-eval: βœ… Implemented, do not check for global shadowing
  • no-meaningless-void-operator: βœ… Implemented
  • no-misused-promises: βœ… Implemented
  • no-misused-spread: βœ… Implemented, no allow option
  • no-mixed-enums: πŸ›‘ TS only concepts are out of core
  • no-redundant-type-constituents: βœ… Implemented and smarter thanks to checker.isTypeAssignableTo
  • no-unnecessary-boolean-literal-compare: βœ… Implemented
  • no-unnecessary-condition: βœ… Implemented
  • no-unnecessary-qualifier: TS only concepts are out of core
  • no-unnecessary-template-expression: βœ… Implemented
  • no-unnecessary-type-arguments: βœ… Implemented
  • no-unnecessary-type-assertion: βœ… Implemented
  • no-unnecessary-type-conversion: βœ… Implemented
  • no-unnecessary-type-parameters: ❌ Not implemented, IMO report too many legitimate cases
  • no-unsafe-argument: ❌ Not implemented, too noisy for me
  • no-unsafe-assignment: ❌ Not implemented, too noisy for me
  • no-unsafe-call: ❌ Not implemented, too noisy for me
  • no-unsafe-enum-comparison: πŸ›‘ TS only concepts are out of core
  • no-unsafe-member-access: ❌ Not implemented, too noisy for me
  • no-unsafe-return: ❌ Not implemented, too noisy for me
  • no-unsafe-type-assertion: ❌ Not implemented, too noisy for me
  • no-unsafe-unary-minus: βœ… Implemented
  • non-nullable-type-assertion-style: βœ… Implemented
  • only-throw-error: βœ… Implemented, allow options is named based only
  • prefer-destructuring: Styling is out of core
  • prefer-find: βœ… Implemented
  • prefer-includes: βœ… Implemented, without /baz/.test(a), it requires regex parsing and can be achieved without type information
  • prefer-nullish-coalescing: βœ… Implemented
  • prefer-optional-chain: βœ… Implemented
  • prefer-promise-reject-errors: πŸ›‘ See Add the patches
  • prefer-readonly: ❌ Not implemented, too OOP for me
  • prefer-readonly-parameter-types: πŸ›‘ Implementation not planned, it would better to check that function parameters are never mutated instead
  • prefer-reduce-type-parameter: βœ… Implemented,
  • prefer-regexp-exec: πŸ›‘ Small runtime optimization are out of core
  • prefer-return-this-type: βœ… Implemented
  • prefer-string-starts-ends-with: βœ… Implemented
  • promise-function-async: ❌ Not implemented
  • related-getter-setter-pairs: ❌ Not implemented, too OOP for me
  • require-array-sort-compare: ❌ Not implemented
  • require-await: πŸ›‘ Implementation not planned, type information to handle async generators, which is a niche case
  • restrict-plus-operands: βœ… Implemented with stricter defaults, always lint assignment
  • restrict-template-expressions: βœ… Implemented, with stricter defaults
  • return-await: βœ… Implemented, only support always, remove unneeded await handled by await-thenable
  • strict-boolean-expressions: βœ… Implemented
  • switch-exhaustiveness-check: βœ… Implemented, missing no default comment #10218
  • unbound-method: ❌ Not implemented, too OOP for me
  • use-unknown-in-catch-callback-variable: πŸ›‘ See Add the patches

Create sharable rules

Use defineRule and createRulesSet that are used for the core rules. You can test your rules using ruleTester, which is still a bit raw.

FAQ

What about tsgo?

I still have to investigate how the language service integration will work with inter-process communication. Probably most of the main rules will have to be ported to Go to be efficient, but having them ported to the TS AST is still a useful step.

What about Biome type-aware support?

Biome 2 introduced a first type-aware lint rule (noFloatingPromises) without using the TypeScript compiler. While I agree that detecting if a function returns a promise doesn't require full type information, various type aware rules require to know if a variable is nullable or not (restrictedTemplateExpressions, restrictedPlusOperands) and this will be hard to implement without the TypeScript compiler for web codebases where most types come from the DB and are resolved through this kind of types:

Extract of Prisma types
export declare type GetPayloadResult<
  Base extends Record<any, any>,
  R extends InternalArgs["result"][string],
> = Omit<Base, GetPayloadResultExtensionKeys<R>>
  & GetPayloadResultExtensionObject<R>;

export declare type GetPayloadResultExtensionKeys<
  R extends InternalArgs["result"][string],
  KR extends keyof R = string extends keyof R ? never : keyof R,
> = KR;

export declare type GetPayloadResultExtensionObject<
  R extends InternalArgs["result"][string],
> = {
  [K in GetPayloadResultExtensionKeys<R>]: R[K] extends () => {
    compute: (...args: any) => infer C;
  }
    ? C
    : never;
};

export type $UserPayload<
  ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs,
> = {
  name: "User";
  objects: {
    analytics: Prisma.$AnalyticsEventPayload<ExtArgs>[];
  };
  scalars: $Extensions.GetPayloadResult<
    {
      uuid: string;
      createdAt: Date;
      updatedAt: Date;
      username: string;
      firstName: string | null;
      lastName: string | null;
    },
    ExtArgs["result"]["user"]
  >;
  composites: {};
};

type UserGetPayload<S extends boolean | null | undefined | UserDefaultArgs> =
  $Result.GetResult<Prisma.$UserPayload, S>;

// prettier-ignore
export interface UserDelegate<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs, ClientOptions = {}> {
  [K: symbol]: { types: Prisma.TypeMap<ExtArgs>['model']['User'], meta: { name: 'User' } }
  findUnique<T extends UserFindUniqueArgs>(args: SelectSubset<T, UserFindUniqueArgs<ExtArgs>>): Prisma__UserClient<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T,"findUnique", ClientOptions> | null, null, ExtArgs, ClientOptions>
  findUniqueOrThrow<T extends UserFindUniqueOrThrowArgs>(args: SelectSubset<T, UserFindUniqueOrThrowArgs<ExtArgs>>): Prisma__UserClient<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", ClientOptions>, never, ExtArgs, ClientOptions>
  findFirst<T extends UserFindFirstArgs>(args?: SelectSubset<T, UserFindFirstArgs<ExtArgs>>): Prisma__UserClient<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T,"findFirst", ClientOptions> | null, null, ExtArgs, ClientOptions>
  findFirstOrThrow<T extends UserFindFirstOrThrowArgs>(args?: SelectSubset<T, UserFindFirstOrThrowArgs<ExtArgs>>): Prisma__UserClient<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findFirstOrThrow", ClientOptions>, never, ExtArgs, ClientOptions>
  findMany<T extends UserFindManyArgs>(args?: SelectSubset<T, UserFindManyArgs<ExtArgs>>): Prisma.PrismaPromise<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T,"findMany", ClientOptions>>
  create<T extends UserCreateArgs>(args: SelectSubset<T, UserCreateArgs<ExtArgs>>): Prisma__UserClient<$Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "create", ClientOptions>, never, ExtArgs, ClientOptions>
  createMany<T extends UserCreateManyArgs>(args?: SelectSubset<T, UserCreateManyArgs<ExtArgs>>): Prisma.PrismaPromise<BatchPayload>
  // More methods...
}

Thanks

  • Josh Goldberg for his work on typescript-eslint, his feedback on the project and his various blog posts on TS linting.
  • All typescript-eslint contributors for the quality of the rules and their test suites which made it possible to port them to the TypeScript AST.
  • Jake Bailey for his help when I was discovering the compiler API.
  • Johnson Chu for his work on TSSlint that pushed me to develop the TypeScript plugin for IDE support and for transferring tsl to me.
  • Basarat Ali Syed for transferring tsl to Johnson Chu in the first place.
  • Romain Liautaud for his help on the configuration and rules API.

About

A modern TypeScript linter for fast type-aware linting

Resources

License

Stars

Watchers

Forks

Packages

No packages published