Skip to content

⬅️ Back to Table of Contents

📄 consistent-type-exports

📊 Analysis Summary

Metric Count
🔧 Functions 7
📦 Imports 11
📐 Interfaces 2
📑 Type Aliases 2

📚 Table of Contents

🛠️ File Location:

📂 packages/eslint-plugin/src/rules/consistent-type-exports.ts

📤 Default Export

export default createRule<Options, MessageIds>({ ... })
Property Value
name 'consistent-type-exports'
meta.type 'suggestion'
meta.docs.description 'Enforce consistent usage of type exports'
meta.docs.requiresTypeChecking true
meta.fixable 'code'
meta.messages.multipleExportsAreTypes 'Type exports {{exportNames}} are not values and should be exported using export type.'
meta.messages.singleExportIsType 'Type export {{exportNames}} is not a value and should be exported using export type.'
meta.messages.typeOverValue 'All exports in the declaration are only used as types. Use export type.'
meta.schema [ { type: 'object', additionalProperties: false, properties: { fixMixedExportsWithInlineTypeSpecifier: { type: 'boole...
defaultOptions [ { fixMixedExportsWithInlineTypeSpecifier: false, }, ]

Entry point: create — documented under Functions.


📦 Imports

Name Source
TSESLint @typescript-eslint/utils
TSESTree @typescript-eslint/utils
AST_NODE_TYPES @typescript-eslint/utils
AST_TOKEN_TYPES @typescript-eslint/utils
createRule ../util
formatWordList ../util
getParserServices ../util
isClosingBraceToken ../util
isOpeningBraceToken ../util
nullThrows ../util
NullThrowsReasons ../util

Functions

create(context: any, [{ fixMixedExportsWithInlineTyp…: any): { ExportAllDeclaration(node: any): void; ExportNamedDeclara…

Parameters:

  • context any
  • [{ fixMixedExportsWithInlineTypeSpecifier }] any

Returns: { ExportAllDeclaration(node: any): void; ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void; 'Program:exit'(): void; }

Calls:

  • getParserServices (from ../util)
  • services.program.getTypeChecker
  • checker.isUnknownSymbol
  • symbol.getDeclarations()?.some
  • tsutils.isSymbolFlagSet
  • isSymbolTypeBased
  • checker.getImmediateAliasedSymbol
  • ts.resolveModuleName
  • services.program.getCompilerOptions
  • services.program.getSourceFile
  • checker.getSymbolAtLocation
  • checker.getTypeOfSymbol
  • checker .getPropertiesOfType(sourceFileType) .some
  • checker.getPropertyOfType
  • propertyTypeSymbol.escapedName.toString
  • context.report
  • nullThrows (from ../util)
  • context.sourceCode.getFirstToken
  • NullThrowsReasons.MissingToken
  • fixer.insertTextBefore
  • getSourceFromExport
  • inlineTypeSpecifiers.push
  • services.getSymbolAtLocation
  • typeBasedSpecifiers.push
  • valueSpecifiers.push
  • sourceExports.reportValueExports.push
  • Object.values
  • fixExportInsertType
  • report.typeBasedSpecifiers.map
  • fixAddTypeSpecifierToNamedExports
  • fixSeparateNamedExports
  • formatWordList (from ../util)

Internal Comments:

/**
     * Helper for identifying if a symbol resolves to a
     * JavaScript value or a TypeScript type.
     *
     * @returns True/false if is a type or not, or undefined if the specifier
     * can't be resolved.
     */
// Module can explicitly export types or values, and it's not difficult (x2)
// to distinguish one from the other, since we can get the flags of (x2)
// the exported symbols or check if symbol export declaration has (x2)
// the "type" keyword in it. (x2)
// (x6)
// Things get a lot more complicated when we're dealing with (x2)
// export * from './module-with-type-only-exports' (x2)
// export type * from './module-with-type-and-value-exports' (x2)
// TS checker has an internal function getExportsOfModuleWorker that (x2)
// recursively visits all module exports, including "export *". It then (x2)
// puts type-only-star-exported symbols into the typeOnlyExportStarMap (x2)
// property of sourceFile's SymbolLinks. Since symbol links aren't (x2)
// exposed outside the checker, we cannot access it directly. (x2)
// Therefore, to filter out value properties, we use the following hack: (x2)
// checker.getPropertiesOfType returns all exports that were originally (x2)
// values, but checker.getPropertyOfType returns undefined for (x2)
// properties that are mentioned in the typeOnlyExportStarMap. (x2)
// Coerce the source into a string for use as a lookup entry. (x2)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition (x2)
// Cache the first encountered exports for the package. We will need to come
// back to these later when fixing the problems.
// The export is a type export (x4)
// The export is a value export (x4)
// Next for the current export, we will separate type/value specifiers. (x2)
// Note: it is valid to export values as types. We will avoid reporting errors
// when this is encountered.
// When isTypeBased is undefined, we should avoid reporting them. (x4)
// If this export has no issues, move on.
// Export is all type-only with no type specifiers; convert the entire export to `export type`. (x4)
// We have both type and value violations. (x2)

Code
create(context, [{ fixMixedExportsWithInlineTypeSpecifier }]) {
    const sourceExportsMap: Record<string, SourceExports> = {};
    const services = getParserServices(context);
    const checker = services.program.getTypeChecker();

    /**
     * Helper for identifying if a symbol resolves to a
     * JavaScript value or a TypeScript type.
     *
     * @returns True/false if is a type or not, or undefined if the specifier
     * can't be resolved.
     */
    function isSymbolTypeBased(
      symbol: ts.Symbol | undefined,
    ): boolean | undefined {
      if (!symbol || checker.isUnknownSymbol(symbol)) {
        return undefined;
      }
      if (
        symbol.getDeclarations()?.some(ts.isTypeOnlyImportOrExportDeclaration)
      ) {
        return true;
      }
      if (tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Value)) {
        return false;
      }
      return tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias)
        ? isSymbolTypeBased(checker.getImmediateAliasedSymbol(symbol))
        : true;
    }

    return {
      ExportAllDeclaration(node): void {
        if (node.exportKind === 'type') {
          return;
        }

        const sourceModule = ts.resolveModuleName(
          node.source.value,
          context.filename,
          services.program.getCompilerOptions(),
          ts.sys,
        );
        if (sourceModule.resolvedModule == null) {
          return;
        }
        const sourceFile = services.program.getSourceFile(
          sourceModule.resolvedModule.resolvedFileName,
        );
        if (sourceFile == null) {
          return;
        }
        const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile);
        if (sourceFileSymbol == null) {
          return;
        }

        const sourceFileType = checker.getTypeOfSymbol(sourceFileSymbol);
        // Module can explicitly export types or values, and it's not difficult
        // to distinguish one from the other, since we can get the flags of
        // the exported symbols or check if symbol export declaration has
        // the "type" keyword in it.
        //
        // Things get a lot more complicated when we're dealing with
        // export * from './module-with-type-only-exports'
        // export type * from './module-with-type-and-value-exports'
        //
        // TS checker has an internal function getExportsOfModuleWorker that
        // recursively visits all module exports, including "export *". It then
        // puts type-only-star-exported symbols into the typeOnlyExportStarMap
        // property of sourceFile's SymbolLinks. Since symbol links aren't
        // exposed outside the checker, we cannot access it directly.
        //
        // Therefore, to filter out value properties, we use the following hack:
        // checker.getPropertiesOfType returns all exports that were originally
        // values, but checker.getPropertyOfType returns undefined for
        // properties that are mentioned in the typeOnlyExportStarMap.
        const isThereAnyExportedValue = checker
          .getPropertiesOfType(sourceFileType)
          .some(
            propertyTypeSymbol =>
              checker.getPropertyOfType(
                sourceFileType,
                propertyTypeSymbol.escapedName.toString(),
              ) != null,
          );
        if (isThereAnyExportedValue) {
          return;
        }

        context.report({
          node,
          messageId: 'typeOverValue',
          fix(fixer) {
            const asteriskToken = nullThrows(
              context.sourceCode.getFirstToken(
                node,
                token =>
                  token.type === AST_TOKEN_TYPES.Punctuator &&
                  token.value === '*',
              ),
              NullThrowsReasons.MissingToken(
                'asterisk',
                'export all declaration',
              ),
            );

            return fixer.insertTextBefore(asteriskToken, 'type ');
          },
        });
      },
      ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void {
        // Coerce the source into a string for use as a lookup entry.
        const source = getSourceFromExport(node) ?? 'undefined';
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        const sourceExports = (sourceExportsMap[source] ||= {
          reportValueExports: [],
          source,
          typeOnlyNamedExport: null,
          valueOnlyNamedExport: null,
        });

        // Cache the first encountered exports for the package. We will need to come
        // back to these later when fixing the problems.
        if (node.exportKind === 'type') {
          // The export is a type export
          sourceExports.typeOnlyNamedExport ??= node;
        } else {
          // The export is a value export
          sourceExports.valueOnlyNamedExport ??= node;
        }

        // Next for the current export, we will separate type/value specifiers.
        const typeBasedSpecifiers: TSESTree.ExportSpecifier[] = [];
        const inlineTypeSpecifiers: TSESTree.ExportSpecifier[] = [];
        const valueSpecifiers: TSESTree.ExportSpecifier[] = [];

        // Note: it is valid to export values as types. We will avoid reporting errors
        // when this is encountered.
        if (node.exportKind !== 'type') {
          for (const specifier of node.specifiers) {
            if (specifier.exportKind === 'type') {
              inlineTypeSpecifiers.push(specifier);
              continue;
            }

            const isTypeBased = isSymbolTypeBased(
              services.getSymbolAtLocation(specifier.exported),
            );

            if (isTypeBased === true) {
              typeBasedSpecifiers.push(specifier);
            } else if (isTypeBased === false) {
              // When isTypeBased is undefined, we should avoid reporting them.
              valueSpecifiers.push(specifier);
            }
          }
        }

        if (
          (node.exportKind === 'value' && typeBasedSpecifiers.length) ||
          (node.exportKind === 'type' && valueSpecifiers.length)
        ) {
          sourceExports.reportValueExports.push({
            node,
            inlineTypeSpecifiers,
            typeBasedSpecifiers,
            valueSpecifiers,
          });
        }
      },

      'Program:exit'(): void {
        for (const sourceExports of Object.values(sourceExportsMap)) {
          // If this export has no issues, move on.
          if (sourceExports.reportValueExports.length === 0) {
            continue;
          }

          for (const report of sourceExports.reportValueExports) {
            if (report.valueSpecifiers.length === 0) {
              // Export is all type-only with no type specifiers; convert the entire export to `export type`.
              context.report({
                node: report.node,
                messageId: 'typeOverValue',
                *fix(fixer) {
                  yield* fixExportInsertType(
                    fixer,
                    context.sourceCode,
                    report.node,
                  );
                },
              });
              continue;
            }

            // We have both type and value violations.
            const allExportNames = report.typeBasedSpecifiers.map(specifier =>
              specifier.local.type === AST_NODE_TYPES.Identifier
                ? specifier.local.name
                : specifier.local.value,
            );

            if (allExportNames.length === 1) {
              const exportNames = allExportNames[0];

              context.report({
                node: report.node,
                messageId: 'singleExportIsType',
                data: { exportNames },
                *fix(fixer) {
                  if (fixMixedExportsWithInlineTypeSpecifier) {
                    yield* fixAddTypeSpecifierToNamedExports(fixer, report);
                  } else {
                    yield* fixSeparateNamedExports(
                      fixer,
                      context.sourceCode,
                      report,
                    );
                  }
                },
              });
            } else {
              const exportNames = formatWordList(allExportNames);

              context.report({
                node: report.node,
                messageId: 'multipleExportsAreTypes',
                data: { exportNames },
                *fix(fixer) {
                  if (fixMixedExportsWithInlineTypeSpecifier) {
                    yield* fixAddTypeSpecifierToNamedExports(fixer, report);
                  } else {
                    yield* fixSeparateNamedExports(
                      fixer,
                      context.sourceCode,
                      report,
                    );
                  }
                },
              });
            }
          }
        }
      },
    };
  }

fixExportInsertType(fixer: TSESLint.RuleFixer, sourceCode: Readonly<TSESLint.SourceCode>, node: TSESTree.ExportNamedDeclaration): IterableIterator<TSESLint.RuleFix>

Inserts "type" into an export.

Example:

export type { Foo } from 'foo'; ^^^^

Raw JSDoc
/**
 * Inserts "type" into an export.
 *
 * Example:
 *
 * export type { Foo } from 'foo';
 *        ^^^^
 */

Calls:

  • nullThrows (from ../util)
  • sourceCode.getFirstToken
  • NullThrowsReasons.MissingToken
  • fixer.insertTextAfter
  • sourceCode.getTokenAfter
  • fixer.removeRange
Code
function* fixExportInsertType(
  fixer: TSESLint.RuleFixer,
  sourceCode: Readonly<TSESLint.SourceCode>,
  node: TSESTree.ExportNamedDeclaration,
): IterableIterator<TSESLint.RuleFix> {
  const exportToken = nullThrows(
    sourceCode.getFirstToken(node),
    NullThrowsReasons.MissingToken('export', node.type),
  );

  yield fixer.insertTextAfter(exportToken, ' type');

  for (const specifier of node.specifiers) {
    if (specifier.exportKind === 'type') {
      const kindToken = nullThrows(
        sourceCode.getFirstToken(specifier),
        NullThrowsReasons.MissingToken('export', specifier.type),
      );
      const firstTokenAfter = nullThrows(
        sourceCode.getTokenAfter(kindToken, {
          includeComments: true,
        }),
        'Missing token following the export kind.',
      );

      yield fixer.removeRange([kindToken.range[0], firstTokenAfter.range[0]]);
    }
  }
}

fixSeparateNamedExports(fixer: TSESLint.RuleFixer, sourceCode: Readonly<TSESLint.SourceCode>, report: ReportValueExport): IterableIterator<TSESLint.RuleFix>

Separates the exports which mismatch the kind of export the given node represents. For example, a type export's named specifiers which represent values will be inserted in a separate export statement.

Raw JSDoc
/**
 * Separates the exports which mismatch the kind of export the given
 * node represents. For example, a type export's named specifiers which
 * represent values will be inserted in a separate `export` statement.
 */

Calls:

  • getSourceFromExport
  • typeSpecifiers.map(getSpecifierText).join
  • nullThrows (from ../util)
  • sourceCode.getFirstToken
  • NullThrowsReasons.MissingToken
  • valueSpecifiers .map(getSpecifierText) .join
  • sourceCode.getLastToken
  • fixer.replaceTextRange
  • fixer.insertTextBefore

Internal Comments:

// Filter the bad exports from the current line. (x2)
// Remove exports from the current line which we're going to re-insert. (x2)
// Insert the bad exports into a new export line above. (x2)

Code
function* fixSeparateNamedExports(
  fixer: TSESLint.RuleFixer,
  sourceCode: Readonly<TSESLint.SourceCode>,
  report: ReportValueExport,
): IterableIterator<TSESLint.RuleFix> {
  const { node, inlineTypeSpecifiers, typeBasedSpecifiers, valueSpecifiers } =
    report;
  const typeSpecifiers = [...typeBasedSpecifiers, ...inlineTypeSpecifiers];
  const source = getSourceFromExport(node);
  const specifierNames = typeSpecifiers.map(getSpecifierText).join(', ');

  const exportToken = nullThrows(
    sourceCode.getFirstToken(node),
    NullThrowsReasons.MissingToken('export', node.type),
  );

  // Filter the bad exports from the current line.
  const filteredSpecifierNames = valueSpecifiers
    .map(getSpecifierText)
    .join(', ');
  const openToken = nullThrows(
    sourceCode.getFirstToken(node, isOpeningBraceToken),
    NullThrowsReasons.MissingToken('{', node.type),
  );
  const closeToken = nullThrows(
    sourceCode.getLastToken(node, isClosingBraceToken),
    NullThrowsReasons.MissingToken('}', node.type),
  );

  // Remove exports from the current line which we're going to re-insert.
  yield fixer.replaceTextRange(
    [openToken.range[1], closeToken.range[0]],
    ` ${filteredSpecifierNames} `,
  );

  // Insert the bad exports into a new export line above.
  yield fixer.insertTextBefore(
    exportToken,
    `export type { ${specifierNames} }${source ? ` from '${source}'` : ''};\n`,
  );
}

fixAddTypeSpecifierToNamedExports(fixer: TSESLint.RuleFixer, report: ReportValueExport): IterableIterator<TSESLint.RuleFix>

Parameters:

  • fixer TSESLint.RuleFixer
  • report ReportValueExport

Returns: IterableIterator<TSESLint.RuleFix>

Calls:

  • fixer.insertTextBefore
Code
function* fixAddTypeSpecifierToNamedExports(
  fixer: TSESLint.RuleFixer,
  report: ReportValueExport,
): IterableIterator<TSESLint.RuleFix> {
  if (report.node.exportKind === 'type') {
    return;
  }

  for (const specifier of report.typeBasedSpecifiers) {
    yield fixer.insertTextBefore(specifier, 'type ');
  }
}

getSourceFromExport(node: TSESTree.ExportNamedDeclaration): string | undefined

Returns the source of the export, or undefined if the named export has no source.

Raw JSDoc
/**
 * Returns the source of the export, or undefined if the named export has no source.
 */
Code
function getSourceFromExport(
  node: TSESTree.ExportNamedDeclaration,
): string | undefined {
  if (
    node.source?.type === AST_NODE_TYPES.Literal &&
    typeof node.source.value === 'string'
  ) {
    return node.source.value;
  }

  return undefined;
}

getSpecifierText(specifier: TSESTree.ExportSpecifier): string

Returns the specifier text for the export. If it is aliased, we take care to return the proper formatting.

Raw JSDoc
/**
 * Returns the specifier text for the export. If it is aliased, we take care to return
 * the proper formatting.
 */
Code
function getSpecifierText(specifier: TSESTree.ExportSpecifier): string {
  const exportedName =
    specifier.exported.type === AST_NODE_TYPES.Literal
      ? specifier.exported.raw
      : specifier.exported.name;
  const localName =
    specifier.local.type === AST_NODE_TYPES.Literal
      ? specifier.local.raw
      : specifier.local.name;

  return `${localName}${
    exportedName !== localName ? ` as ${exportedName}` : ''
  }`;
}

Internal helpers

Declared inside another function in this file.

isSymbolTypeBased(symbol: ts.Symbol | undefined): boolean | undefined

Helper for identifying if a symbol resolves to a JavaScript value or a TypeScript type.

Returns: undefined True/false if is a type or not, or undefined if the specifier can't be resolved.

Raw JSDoc
/**
     * Helper for identifying if a symbol resolves to a
     * JavaScript value or a TypeScript type.
     *
     * @returns True/false if is a type or not, or undefined if the specifier
     * can't be resolved.
     */

Calls:

  • checker.isUnknownSymbol
  • symbol.getDeclarations()?.some
  • tsutils.isSymbolFlagSet
  • isSymbolTypeBased
  • checker.getImmediateAliasedSymbol
Code
function isSymbolTypeBased(
      symbol: ts.Symbol | undefined,
    ): boolean | undefined {
      if (!symbol || checker.isUnknownSymbol(symbol)) {
        return undefined;
      }
      if (
        symbol.getDeclarations()?.some(ts.isTypeOnlyImportOrExportDeclaration)
      ) {
        return true;
      }
      if (tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Value)) {
        return false;
      }
      return tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias)
        ? isSymbolTypeBased(checker.getImmediateAliasedSymbol(symbol))
        : true;
    }

Interfaces

SourceExports

Interface Code
interface SourceExports {
  reportValueExports: ReportValueExport[];
  source: string;
  typeOnlyNamedExport: TSESTree.ExportNamedDeclaration | null;
  valueOnlyNamedExport: TSESTree.ExportNamedDeclaration | null;
}

Properties

Name Type Optional Description
reportValueExports ReportValueExport[] not shown
source string not shown
typeOnlyNamedExport TSESTree.ExportNamedDeclaration \| null not shown
valueOnlyNamedExport TSESTree.ExportNamedDeclaration \| null not shown

ReportValueExport

Interface Code
interface ReportValueExport {
  inlineTypeSpecifiers: TSESTree.ExportSpecifier[];
  node: TSESTree.ExportNamedDeclaration;
  typeBasedSpecifiers: TSESTree.ExportSpecifier[];
  valueSpecifiers: TSESTree.ExportSpecifier[];
}

Properties

Name Type Optional Description
inlineTypeSpecifiers TSESTree.ExportSpecifier[] not shown
node TSESTree.ExportNamedDeclaration not shown
typeBasedSpecifiers TSESTree.ExportSpecifier[] not shown
valueSpecifiers TSESTree.ExportSpecifier[] not shown

Type Aliases

Options

type Options = [
  {
    fixMixedExportsWithInlineTypeSpecifier: boolean;
  },
];

MessageIds

type MessageIds = 'multipleExportsAreTypes' | 'singleExportIsType' | 'typeOverValue';

Generated by Syntax Scribe