TypeScript
No classes
Supported by linter: Yes, via
functional/no-class
We keep this codebase FP-oriented and focused on the KISS principle. No classes should be present to avoid dealing with state induced problems (and just general FP preference).
This codebase should only include functions and types. Nothing else.
This is especially true for static class methods. Never do that. Simply create functions and use them as you would.
Incorrect:
class AccountService {
constructor(private readonly repository: AccountRepository) {}
getAccounts() {
return repository.getAccounts();
}
}
const accountService = new AccountService(accountRepository);
const accounts = await accountService.getAccounts();
Correct:
function makeAccountService(repository: AccountRepository) {
function getAccounts() {
return repository.getAccounts();
}
// "private" functions = not returned
return { getAccounts };
}
const accountService = makeAccountService(accountRepository);
const accounts = await accountService.getAccounts();
Prefer types to interfaces
Supported by linter: Yes, via
eslint-plugin-prefer-type-alias
Interfaces are useless when types exist. They both do the same job, but types are stricter and are more FP-oriented.
Incorrect:
interface AccountRepository {
getAccounts(): Promise<Account[]>;
}
Correct:
type AccountRepository = {
getAccounts(): Promise<Account[]>;
};
Even better, when you don’t need multiple implementations:
function makeAccountRepository() {
function getAccounts() {
return [];
}
}
type AccountRepository = ReturnType<typeof makeAccountRepository>;
No arrow functions
Supported by linter: Yes, via func-style
Arrow functions can bring confusion, it’s hard to see what is a value and what is a function. To avoid this situation,
always use function
.
Incorrect:
const double = (a: number) => a * 2;
Correct:
function double(a: number) {
return a * 2;
}
Exceptions
This only applies to arrow functions declared as the given examples. For functions in type definitions or for simple lambdas sent as params, arrow functions are sometimes best suited.
Examples:
type SomeType = {
someValue: string;
someFunction: () => Promise<void>;
};
const filteredValues = values.filter(value => value.someValue === 'someValue');
No function return types
Supported by linter: NO
Specifying function return types often leads to imports only used for this exact reason. To avoid this, we can let our IDEs tell us when a function returns an unwanted type. This will also be caught be the CI when building the app.
Incorrect:
import { Account } from 'types/accounts';
function getAccounts(): Promise<Account[]> {
return [{ firstName: 'John', lastName: 'Doe' }];
}
Correct:
function getAccounts() {
return [{ firstName: 'John', lastName: 'Doe' }];
}
Wrap multiple params in a type
Supported by linter: NO
For readability and usability, we prefer using a single type as function param. This reduces ripple effect when we want to switch around params placement and lets the function user decide in which order params are sent (defined in type). This also reduces the amount of param type definition from n (number of values) to 1 (props type).
When there is a single param, you can choose to either keep it this way or use a props type. Your call.
Incorrect:
// Definition
function getAccountUri(baseUrl: string, accountId: string) {
return `${baseUrl}/accounts/${accountId}`;
}
// Usage
const accountUri = getAccountUri('https://example.com', '123');
Correct:
// Definition
type Props = {
baseUrl: string;
accountId: string;
};
function getAccountUri({ baseUrl, accountId }: Props) {
return `${baseUrl}/accounts/${accountId}`;
}
// Usage
const accountUri = getAccountUri({ baseUrl: 'https://example.com', accountId: '123' });
// Definition (this is okay, but a props type is better
function getAccountsUri(baseUrl: string) {
return `${baseUrl}/accounts`;
}
// Usage
const accountUri = getAccountUri('https://example.com');
Imports first, exports last
Supported by linter: Yes, via
import/first
and
import/exports-last
We should read script files (including JS/TS) as functions, with params (imports) and returned values (exports). This means files should always start with imports and end with exports.
Separate declarations from exports.
Incorrect:
import { useState } from 'react';
export default function AccountPage() {
const [account, setAccount] = useState<Account>(null);
return <Container>Something<Container/>;
}
const Container = styled.div`
color: black;
`;
Correct:
import { useState } from 'react';
function AccountPage() {
const [account, setAccount] = useState<Account>(null);
return <Container>Something<Container/>;
}
const Container = styled.div`
color: black;
`;
export default AccountPage;
Avoid state mutation
Supported by linter: Yes, via
functional/immutable-data
Always make sure not to mutate the state without re-creating it. Best example is mutating function params. Remember to always return the new value.
Incorrect:
function updateFirstName({ account, firstName }: UpdateFirstNameParams) {
account.firstName = firstName;
return account;
}
Correct:
function updateFirstName({ account, firstName }: UpdateFirstNameParams) {
return {
...account, // Shallow copy, used as an example
firstName,
};
}
No inverted if statement
Supported by linter: Yes, via no-negated-condition
In an if/else
statement, we want to avoid having a negation as the main condition. This helps the readability of our
code. Instead, we want to have a positive condition for the if
and let the negation be part of the else
statement.
It’s okay to have negated arguments in a simple if
statement when there is no else
.
Incorrect:
if (!account.hasPlan) {
// Logic if account has no plan
} else {
// Logic if account has plan
}
Correct:
if (account.hasPlan) {
// Logic if account has plan
} else {
// Logic if account has no plan
}
// This is okay, since there is no else statement
if (!account.hasPlan) {
// Logic if account has no plan
}
No unnecessary else statement
Supported by linter: Yes, via no-else-return
In the case of a returned value, it is unnecessary to return in an else statement when the if statement returns.
Incorrect:
if (account.hasPlan) {
return createBillForPlan(account.plan);
} else {
return createBillForAccount(account);
}
Correct:
if (account.hasPlan) {
return createBillForPlan(account);
}
return createBillForAccount(account);
// Even better
return account.hasPlan ? createBillForPlan(account) : createBillForAccount(account);
No complex ternary if statement
Supported by linter: Partly, via no-nested-ternary
Ternary if statements are nice, but they can get overly complicated. This can occur when they hold much logic or when they are chained together.
Incorrect:
return account.hasPlan
? {
plan: account.plan,
something,
metadata: {
whatever,
},
}
: {
account,
somethingElse,
metadata: {
whatever,
},
};
return account.hasPlan
? createBillForPlan(account)
: account.canBeBilled
? createBillForAccount(account)
: throw createAccountBillingError(account);
Correct:
return account.hasPlan ? createBillingResponseForPlan(account.plan) : createBillingResponseForAccount(account); // In this example, we'd throw an error on a condition in this function
Prefer array functions over for-loops
Supported by linter: NO
Incorrect:
for (const element in array) {
console.log(element);
}
const newArray = [];
for (const element in array) {
newArray.push(convert(element));
}
const newArray = [];
for (const element in array) {
if (someFilteringFunction(element)) {
newArray.push(element);
}
}
Correct:
array.forEach(console.log);
array.forEach(element => someFunction(element, anotherValue));
const newArray = array.map(convert);
const newArray = array.filter(someFilteringFunction);
Use Record over switch-case statements
Supported by linter: NO
Incorrect:
// Definition
function getColors(mode: ThemeMode) {
switch (mode) {
default:
case 'light':
return lightColors;
case 'dark':
return darkColors;
}
}
// Usage
const colors = getColors(mode);
// Definition
const modeToColors: Record<ThemeMode, Colors> = {
light: lightColors,
dark: darkColors,
};
// Usage
const colors = modeToColors[mode];