This document outlines the TypeScript coding conventions and standards for all Bayat projects. It builds upon our JavaScript Coding Conventions with additional TypeScript-specific guidelines.
- TypeScript Version
- Type Definitions
- Interfaces and Types
- Classes
- Generics
- Enums
- Null and Undefined
- Type Assertions
- TSConfig Standards
- Documentation
- Testing TypeScript Code
- Migration Strategies
- Specify TypeScript version in
package.json
- Document the minimum supported TypeScript version in README.md
- Stay within one major version of the latest stable release
- Prefer explicit typing over implicit (inferred) typing for function parameters and return types
- Use type inference for local variables when types are obvious
- Use
unknown
instead ofany
when possible - Avoid type assertions except when necessary
- Use union types to represent values that could be one of several types
// Good
function calculateTotal(items: Item[], taxRate: number): number {
// Type of total inferred as number
let total = 0;
for (const item of items) {
total += item.price;
}
return total * (1 + taxRate);
}
// Avoid
function calculateTotal(items, taxRate) {
let total = 0;
for (const item of items) {
total += item.price;
}
return total * (1 + taxRate);
}
- Use
: Type
syntax for variable and parameter type annotations - Use
: => Type
syntax for function return type annotations - Place type annotations after parameter/variable names
- Add spaces around the colon in type annotations
// Good
const age: number = 30;
function greet(name: string): string {
return `Hello, ${name}!`;
}
// Avoid
const age:number = 30;
function greet(name:string):string {
return `Hello, ${name}!`;
}
- Use
interface
for object definitions that can be extended or implemented - Use
type
for unions, primitives, tuples, or when you need to use operations like mapped types - Prefer
interface
overtype
for public API definitions - Be consistent in your codebase
// Interface for object definitions that may be extended
interface User {
id: number;
name: string;
email: string;
}
// Extended interface
interface AdminUser extends User {
permissions: string[];
}
// Type for union types
type Status = 'pending' | 'active' | 'closed';
// Type for complex types that use mapped types or other operations
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
- Use PascalCase for interface and type names
- Do not use "I" prefixes for interface names (e.g., use
User
notIUser
) - Use declarative, descriptive names
- Suffix interface with a noun or adjective describing its purpose
// Good
interface Button {
label: string;
onClick: () => void;
}
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// Avoid
interface IButton {
label: string;
onClick: () => void;
}
interface Response<T> {
data: T;
status: number;
message: string;
}
- Use optional properties (
?
) rather than properties that can be undefined - Use readonly modifier for properties that should not be modified after creation
- Group required properties before optional properties
- Add JSDoc comments for complex properties
interface User {
// Required properties
id: number;
name: string;
// Optional properties
email?: string;
phoneNumber?: string;
// Readonly properties
readonly createdAt: Date;
}
- Use visibility modifiers (
public
,private
,protected
) for all class members - Use
private
instead of JavaScript's#
private fields for consistency - Use the
readonly
modifier for properties that should not change after initialization - Define member visibility in this order: public, protected, private
class Product {
// Public properties
public name: string;
public readonly id: string;
// Protected properties
protected category: string;
// Private properties
private _price: number;
constructor(name: string, id: string, category: string, price: number) {
this.name = name;
this.id = id;
this.category = category;
this._price = price;
}
// Public methods
public getFormattedPrice(): string {
return `$${this._price.toFixed(2)}`;
}
// Protected methods
protected calculateDiscount(percent: number): number {
return this._price * (1 - percent / 100);
}
// Private methods
private validatePrice(price: number): boolean {
return price > 0;
}
}
- Use abstract classes for base classes that should not be instantiated directly
- Prefer composition over inheritance when possible
- Be explicit about which methods can be overridden by using the
protected
modifier - Use explicit abstract method and property declarations
abstract class Shape {
protected color: string;
constructor(color: string) {
this.color = color;
}
abstract calculateArea(): number;
public getColor(): string {
return this.color;
}
}
class Circle extends Shape {
private radius: number;
constructor(color: string, radius: number) {
super(color);
this.radius = radius;
}
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
}
- Use PascalCase single-letter names (e.g.,
T
,K
,V
) for simple generic type parameters - Use descriptive names for complex generic type parameters (e.g.,
TItem
,TKey
,TValue
) - Use constraints to limit generic types when necessary
- Set meaningful defaults for generic types when appropriate
// Simple generic type
function identity<T>(value: T): T {
return value;
}
// Descriptive generic types with constraints
interface Repository<TEntity extends { id: string }> {
findById(id: string): Promise<TEntity | null>;
save(entity: TEntity): Promise<void>;
}
// Generic with default
interface ApiClient<TError = Error> {
fetch<TData>(url: string): Promise<TData | TError>;
}
- Use PascalCase for enum names
- Use PascalCase for enum members
- Prefer const enums for better performance when applicable
- Use string or number values explicitly when they have meaning
// Numbered enum (default)
enum Direction {
North,
East,
South,
West
}
// String enum (preferred for readability and debugging)
enum HttpStatus {
OK = "OK",
NotFound = "NOT_FOUND",
InternalServerError = "INTERNAL_SERVER_ERROR"
}
// Const enum for performance
const enum LogLevel {
Debug,
Info,
Warning,
Error
}
- Import enums directly rather than referencing individual values
- Use dot notation to access enum values
- When using TypeScript with strict mode, always handle all enum cases in switch statements
import { HttpStatus } from './http-status';
function handleResponse(status: HttpStatus): void {
switch (status) {
case HttpStatus.OK:
// Handle OK response
break;
case HttpStatus.NotFound:
// Handle not found
break;
case HttpStatus.InternalServerError:
// Handle server error
break;
default:
// Ensure exhaustiveness checking
const exhaustiveCheck: never = status;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
- Enable
strictNullChecks
in TSConfig - Use
undefined
for uninitialized values - Use
null
for intentionally absent values - Use optional chaining (
?.
) and nullish coalescing (??
) operators - Be explicit about null/undefined in function return types
// Function that might return undefined
function findUser(id: string): User | undefined {
return users.find(user => user.id === id);
}
// Handling possible undefined return
const user = findUser('123');
const userName = user?.name ?? 'Guest';
// Function with explicit null return
function getUserById(id: string): User | null {
const user = database.query(`SELECT * FROM users WHERE id = ${id}`);
return user || null;
}
- Use type assertions only when you know more about the type than TypeScript does
- Prefer type guards over type assertions
- Use
as
syntax over angle brackets (<>
) for consistency with JSX - Use
unknown
as an intermediate step when asserting to a specific type fromany
// Type assertion when you know the type
const button = document.getElementById('submit') as HTMLButtonElement;
// Prefer type guards when possible
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processValue(value: unknown): void {
if (isString(value)) {
// TypeScript knows value is a string here
console.log(value.toUpperCase());
}
}
// Safer assertions using unknown
const data: unknown = JSON.parse(responseText);
const user = (data as unknown) as User;
Establish standard TSConfig settings for projects:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
- Document any deviations from the standard configuration
- Use
extends
to build on the base configuration - Create environment-specific configurations when necessary (browser vs. Node.js)
- Use JSDoc comments for public APIs
- Leverage TypeScript's type system to reduce redundant documentation
- Focus on documenting "why" rather than "what" when the type signature is clear
- Document constraints and edge cases not obvious from types
/**
* Represents a user in the system
*/
interface User {
/** Unique identifier for the user */
id: string;
/** User's full name */
name: string;
/** User's email address */
email: string;
}
/**
* Retrieves a user by their ID
*
* @param id - The user's unique identifier
* @returns The user object if found, null otherwise
*
* @throws {ApiError} If the API request fails
*/
async function getUserById(id: string): Promise<User | null> {
try {
const response = await api.get(`/users/${id}`);
return response.data;
} catch (error) {
if (error.status === 404) {
return null;
}
throw new ApiError('Failed to fetch user', error);
}
}
- Use
dtslint
or similar tools to test type definitions - Write tests that would fail to compile if types are incorrect
- Test edge cases in generic types
- Use TypeScript-compatible testing frameworks (Jest, Vitest, etc.)
- Configure testing tools to use the same TypeScript settings as your project
- Test type guards and custom type predicates explicitly
// Type guard
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
// Testing the type guard
describe('isUser', () => {
it('returns true for valid user objects', () => {
const user = { id: '123', name: 'John', email: '[email protected]' };
expect(isUser(user)).toBe(true);
});
it('returns false for non-user objects', () => {
const notUser = { id: '123', name: 'John' };
expect(isUser(notUser)).toBe(false);
});
});
- Use incremental migration approaches
- Start with
allowJs: true
in TSConfig - Add
.ts
files alongside.js
files - Add type definitions for existing JavaScript libraries
- Use
@ts-check
and JSDoc types for JavaScript files before conversion
- Enable strict flags incrementally:
noImplicitAny
strictNullChecks
strictFunctionTypes
strictBindCallApply
strictPropertyInitialization
noImplicitThis
alwaysStrict
- Use
// @ts-ignore
sparingly during migration - Document technical debt with
// TODO:
comments
- TypeScript Documentation
- TypeScript Deep Dive
- Google TypeScript Style Guide
- Microsoft TypeScript Coding Guidelines
Version | Date | Description |
---|---|---|
1.0 | 2025-03-20 | Initial version |