Conventions

Prev Next

Rocket.Chat follows a set of coding conventions to ensure that our codebase remains clear, consistent, and maintainable. Adhering to these guidelines helps improve collaboration, reduces technical debt, and makes the project easier to scale.

Below are some important recommendations to follow when writing TypeScript.

TypeScript general tips

Avoid CommonJS features

Prefer ES Modules over CommonJS when writing TypeScript. Avoid using CommonJS constructs such as require and module.exports alongside ES module syntax like import and export.

ES modules offer better portability, improved tooling support, and align with modern JavaScript standards.

Unlike CommonJS, ES modules do not support synchronous conditional imports.

Valid: CommonJS conditional export

CommonJS supports conditional loading because require executes at runtime.

// commonjs.ts

if (condition) {
  const foo = require('foo');
  module.exports = foo;
} else {
  module.exports = {};
}

Invalid: Conditional ES module import

ES module imports are statically analyzed and must be declared at the top level. Conditional imports like the example below will not compile.

// esmodule.ts

if (condition) {
  import foo from 'foo';
  export default foo;
} else {
  export default {};
}

Prefer import type over import

When importing types only, always use import type instead of a regular import.

Although the standard import syntax works in both JavaScript and TypeScript, it has runtime implications. A regular import may cause the imported module to be included in the generated bundle, even if it is only used for type checking.

In contrast, import type is erased during compilation. It is a TypeScript-only construct and ensures that no unnecessary runtime code is emitted.

Using import type helps:

  • Prevent unintended module inclusion in the bundle

  • Reduce bundle size

  • Make type-only dependencies explicit

  • Improve clarity between runtime and type usage

Examples:

Foo.ts

export class Foo {
  bar: string;

  constructor(bar: string) {
    this.bar = bar;
  }
}

Bar.ts

export class Bar {
  foo: Foo;

  constructor(foo: Foo) {
    this.foo = foo;
  }
}

index.ts

import { Foo } from './Foo';
import type { Bar } from './Bar';

declare const foo: Foo;
declare const bar: Bar;

Transpiled output (index.js)

import { Foo } from './Foo';

Notice that Bar is not included in the emitted JavaScript because it was imported using import type.

Avoid using classes as namespaces

Avoid using classes purely as containers for functions (i.e., as namespaces).

Do not use this pattern:

// foo.ts
class Foo {
  bar(): void {
    // ...ts
  }
}

export const foo = new Foo();

// index.ts
import { foo } from './foo';

foo.bar();

In this example, the class is used only to group functions. It does not encapsulate meaningful state or behavior. This adds unnecessary abstraction and complexity.

Preferred approach: use the module as the singleton

When you only need to group related functions or variables, use the module itself as the namespace.

// foo.ts
export function bar(): void {
  // ...
}

// index.ts
import * as foo from './foo';

foo.bar();

This approach:

  • Keeps the code simpler

  • Avoids unnecessary instantiation

  • Improves readability and tree-shaking

Valid exception: managing internal state

A class is acceptable when it encapsulates internal state and provides controlled access to it.

// foo.ts
class Foo {
  baz: number;

  bar(): void {
    // perform actions referencing and modifying `this.baz`
  }
}

export const foo = new Foo();

// index.ts
import { foo } from './foo';

foo.bar();

In this case, using a class is justified because:

  • It manages internal state (baz)

  • It provides a controlled interface (bar)

  • It represents meaningful behavior, not just grouping

Avoid using any (except as a generic constraint)

Avoid using any in most cases. Instead, prefer safer alternatives such as unknown:

  • unknown is the universal type and represents any possible value.

  • any is not a real type, it disables TypeScript’s type checking.

  • Using any removes type safety and can hide bugs.

  • Using unknown forces proper type narrowing before usage.

Example: any vs unknown

❌ Using any :

// Avoid using any
declare const foo: any;

foo.bar(); // No compilation error

Because foo is any, TypeScript allows any operation, even if it’s unsafe.

Using unknown :

// Prefer using unknown
declare const bar: unknown;

bar.baz(); // Compilation error

TypeScript correctly prevents unsafe access.

You must narrow the type first:

const hasBaz = (bar: unknown): bar is { baz(): void } =>
  typeof bar === 'object' &&
  bar !== null &&
  'baz' in bar &&
  typeof (bar as { baz: unknown }).baz === 'function';

if (hasBaz(bar)) {
  bar.baz(); // No compilation error
}

This results in safer and more explicit code.

Valid exception: generic type constraints

An important exception is when any is used as part of a generic constraint, particularly with function types:

type X<F> = F extends (x: unknown) => void ? true : false;
type Y<F> = F extends (x: any) => void ? true : false;

type A = X<(x: string) => void>; // `false`, because x is not `unknown`
type B = Y<(x: string) => void>; // `true`, because x is anything

In this scenario:

  • unknown is restrictive.

  • any allows broader compatibility.

  • Using any here is intentional and correct.


Migrating from JavaScript

TypeScript is a superset of JavaScript

TypeScript builds on top of JavaScript. This means that when migrating from JavaScript to TypeScript, you can continue using the same syntax and patterns you’re already familiar with.

Tools like the TypeScript compiler (tsc) and eslint may report additional warnings or errors. Most of these are designed to enforce best practices and improve code quality. In many cases, they help identify potential issues early. Some warnings can be temporarily ignored during migration, especially if they do not affect runtime behavior, but they should be addressed over time.

JSDoc

When the allowJs option is enabled in your tsconfig.json, TypeScript can analyze JavaScript files and understand type information provided through JSDoc comments.

This is particularly useful during gradual migration from JavaScript to TypeScript. It allows you to introduce type safety without immediately converting files to .ts.

Consider the following JavaScript example:

// module.js
/**
 * @typedef {Object} Foo
 * @property {string} bar
 * @property {string} qux
 */
export const foo = {
  bar: 'baz'
};

foo.qux = 'quux';

By default, tsc may infer the type of foo as { bar: string }, ignoring the qux property because it is added later. This results in incomplete type information.

To ensure TypeScript understands the full structure of the object, you can explicitly define the type using JSDoc.

Alternatively, you can use the @type tag with syntax similar to TypeScript’s type declarations:

// module.js
/**
 * @type {{ bar: string; qux: string }}
 */
export const foo = {
  bar: 'baz'
};

foo.qux = 'quux';

Both approaches help TypeScript correctly recognize the intended structure of foo.

Using JSDoc in this way makes JavaScript files more type-aware and helps smooth the transition to TypeScript without requiring a full rewrite.

Declare a *.d.ts file

When migrating larger JavaScript modules to TypeScript, it is strongly recommended to start by creating a declaration file (.d.ts).

While the migration process can be complex, a dedicated declaration file acts as a clear contract for your module. TypeScript uses .d.ts files to understand the shape of a module, including its exports and public API, without requiring the implementation to be rewritten immediately.

You can think of a .d.ts file as the interface of a module. It defines what the module exposes, while the actual logic can remain in JavaScript during the transition phase.

For large modules, creating a declaration file also helps you:

  • Break down responsibilities into smaller, more manageable pieces

  • Clarify the module’s public surface

  • Plan refactoring before fully converting the implementation

  • Go beyond what JSDoc can realistically provide in complex cases

Because of these benefits, starting with a *.d.ts file is highly recommended when converting significant JavaScript modules to TypeScript.

Here’s an example of a *.d.ts file for a hypothetical module:

// hugeModule.d.ts
export function foo(): void; // maybe it will be placed in another module
export function bar(): void; // maybe it will be placed in another module

In this example, the declaration file defines the exported API without including any implementation details. This allows TypeScript to type-check consumers of the module even before the module itself is fully migrated.

React

Most of the recommendations in this section are inspired by Alex Kondov's Tao of React.

Components

  1. Prefer functional components

    React originally introduced class components to leverage JavaScript class syntax for managing state and lifecycle methods. However, class components have notable drawbacks:

    • They tend to be more verbose.

    • They often encourage misuse of inheritance through extends and super.

    With the introduction of Hooks, React provided a simpler and more flexible way to manage state and side effects. Hooks preserve the core idea of components as render functions, eliminating the need for classes and streamlining development.

  2. Declare one component per file

    Although colocation can be useful, it is not consistently recommended for defining multiple React components within a single file.

    In practice, this pattern is often misused. What starts as a small addition (for example, adding a modal next to a page component) can quickly grow into a cluttered file with loosely related components, making maintenance and readability more difficult. Keeping one component per file improves clarity, organization, and long-term maintainability.

  3. Name components

    Failing to name a component is a common mistake that can significantly impact debugging.

    Unnamed components result in:

    • Less informative stack traces

    • Reduced clarity in React DevTools

    • Harder navigation when inspecting component trees

    There are two recommended ways to properly name a component:

    • Option 1: Use a named function:

      const Foo = () => {
        return <div>Foo</div>;
      };
      
      console.log('The component name is:', Foo.name);
    • Option 2: Set the displayName property:

      const Foo = memo(() => {
        return <div>Foo</div>;
      });
      
      Foo.displayName = 'Foo'; // `Foo.name` is `undefined`
      
      console.log('The component name is:', Foo.displayName);

  4. Use default export at the end of file

    Although named exports are often preferred in general, using a default export for React components improves readability and works especially well with Higher-Order Components (HOCs) such as memo and forwardRef. It also integrates more cleanly with code-splitting patterns like React.lazy.

    Recommended approach:

    This pattern:

    • Preserves the component’s name

    • Keeps the file structure clean

    • Works naturally with lazy imports

    // Component.tsx
    
    import { memo } from 'react';
    
    type ComponentProps = {
      name: string;
    };
    
    // It is NOT an anonymous function
    const Component = (props: ComponentProps) => {
      return <div>Hello, {props.name}</div>;
    };
    
    export default memo(Component); // the component name is preserved
    // index.ts
    
    import { lazy } from 'react';
    
    const Component = lazy(() => import('./Component'));

    This approach avoids extra wrapping logic when using lazy.


    Less readable alternative (named export)

    Using named exports with HOCs often leads to:

    • Anonymous component functions

    • Manual displayName assignment

    • Extra work when using lazy

    // Component.tsx
    
    import { memo } from 'react';
    
    type ComponentProps = {
      name: string;
    };
    
    // It is an anonymous function
    export const Component = memo((props: ComponentProps) => {
      return <div>Hello, {props.name}</div>;
    });
    
    Component.displayName = 'Component'; // needed for React Dev Tools
    // index.ts
    
    import { lazy } from 'react';
    
    const Component = lazy(async () => {
      const { Component } = await import('./Component');
      return { default: Component }; // need to reconstruct the default export
    });

    Here, you must manually reconstruct a default export to make lazy work correctly.

  5. Extract helper functions

    With the adoption of React Hooks, it’s common to define helper functions directly inside components. This is convenient because functions can access variables from the surrounding scope without explicitly receiving them as arguments. However, this pattern has downsides:

    const Component = () => {
      const value = useMyHook();
    
      const isValueOK = () => value === 'OK';
    
      return isValueOK() ? <>OK</> : null;
    };

    Helper functions are generally expected to be pure, meaning their output depends only on their inputs.

    In the example above:

    • isValueOK implicitly depends on value from the outer scope.

    • This makes the function less explicit and therefore less predictable.

    • On every render, the function is recreated.

    • If passed as a prop, it may cause unnecessary re-renders unless wrapped in useCallback.

    While redefining functions per render is usually inexpensive, it can introduce subtle complexity when optimizing components.

    Preferred approach

    Extract helper functions and pass dependencies explicitly:

    const isValueOK = (value: string) => value === 'OK';
    
    const Component = () => {
      const value = useMyHook();
    
      return isValueOK(value) ? <OK /> : null;
    };

    This is better because:

    • The helper is pure and easier to test.

    • Dependencies are explicit.

    • The function is not recreated on every render.

    • The component becomes easier to reason about.