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();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 errorTypeScript 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 anythingMigrating 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 moduleIn 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
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
extendsandsuper.
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.
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.
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
displayNameproperty:const Foo = memo(() => { return <div>Foo</div>; }); Foo.displayName = 'Foo'; // `Foo.name` is `undefined` console.log('The component name is:', Foo.displayName);
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
memoandforwardRef. It also integrates more cleanly with code-splitting patterns likeReact.lazy.Recommended approach:
This pattern:
Preserves the component’s name
Keeps the file structure clean
Works naturally with
lazyimports
// 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
displayNameassignmentExtra 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
lazywork correctly.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:
isValueOKimplicitly depends onvaluefrom 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; };