-
Notifications
You must be signed in to change notification settings - Fork 81
TypeScript Style Guide
partially stolen from our partner startup Google /s
- File Naming
- TypeScript
- Naming Conventions
- Imports
- Types
- Functions
- React
- Comments and Documentation
- Styling
Use PascalCase for React component files and camelCase for all other TypeScript files:
// Good
components / Button.tsx;
hooks / useSchedules.ts;
types / Course.ts;
lib / getSiteSupport.ts;
utils / formatDate.ts;
// Bad
components / button.tsx;
hooks / UseSchedules.ts;
types / course.ts;Enable strict TypeScript checking:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true
}
}Prefer explicit return types for exported functions:
// Good
export function getCourseById(id: number): Course | undefined {
return courses.find(c => c.id === id);
}
// Acceptable for simple cases
export function getCourseById(id: number) {
return courses.find(c => c.id === id);
}Use type keyword for type-only imports:
// Good
import type { Course } from './types/Course';
import type { UserSchedule } from './types/UserSchedule';
// Bad
import { Course } from './types/Course';Never use any. Use unknown for truly unknown types:
// Good
function parseData(data: unknown): Course {
if (typeof data === 'object' && data !== null) {
return data as Course;
}
throw new Error('Invalid data');
}
// Bad
function parseData(data: any): Course {
return data;
}Avoid enums. Use const objects with as const:
// Good
export const Status = {
OPEN: 'OPEN',
CLOSED: 'CLOSED',
WAITLISTED: 'WAITLISTED',
} as const;
export type StatusType = (typeof Status)[keyof typeof Status];
// Bad
enum Status {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
}Prefer undefined for optional values. Use null for explicit absence:
// Good
function findCourse(id: number): Course | undefined {
return courses.find(c => c.id === id);
}
interface Props {
description?: string; // undefined if not provided
}
// Use null for explicit "no value" state
let selectedCourse: Course | null = null;-
camelCasefor variables, functions, methods, parameters -
PascalCasefor classes, interfaces, types, React components -
UPPER_SNAKE_CASEfor global constants - Avoid abbreviations unless widely understood
// Good
const courseList = [];
function getCourseById(id: number) {}
async function fetchCourses() {}
// Bad
const CourseList = [];
const active_schedule = schedules[0];
function GetCourseById(id: number) {}// Good
const MAX_COURSES = 10;
const API_URL = 'https://api.example.com';
// Bad
const maxCourses = 10;
const apiUrl = 'https://api.example.com';// Good
interface UserSchedule {}
type StatusType = 'OPEN' | 'CLOSED';
class Course {}
// Bad
interface userSchedule {}
type status_type = 'OPEN' | 'CLOSED';
class course {}Prefix private fields with # or use TypeScript's private:
// Good
class Course {
#internalId: string;
private metadata: object;
public get id(): string {
return this.#internalId;
}
}Imports should automatically be organized through the linter. For reference, organize imports in this order:
- External libraries
- Internal absolute imports
- Relative imports
// Good
import { useState, useEffect } from 'react';
import clsx from 'clsx';
import { Course } from '@shared/types/Course';
import { Button } from '@views/components/Button';
import styles from './Component.module.scss';
import { helper } from './helper';
// Bad - mixed order
import styles from './Component.module.scss';
import { useState } from 'react';
import { Course } from '@shared/types/Course';Use destructured imports for specific exports:
// Good
import { useState, useEffect } from 'react';
import { getCourseById, formatCourseName } from './utils';
// Use default imports when appropriate
import React from 'react';
import clsx from 'clsx';
// Avoid
import * as React from 'react';Place side-effect imports at the top:
// Good
import 'uno.css';
import './global.scss';
import React from 'react';
import { Button } from './components/Button';Use interface for object shapes that may be extended. Use type for unions, intersections, and utility types:
// Good - Interface for object shapes
interface Course {
id: number;
name: string;
}
interface AdvancedCourse extends Course {
prerequisites: string[];
}
// Good - Type for unions and utilities
type Status = 'OPEN' | 'CLOSED' | 'WAITLISTED';
type CourseOrSchedule = Course | Schedule;
type PartialCourse = Partial<Course>;Use optional properties instead of explicit undefined:
// Good
interface Props {
name: string;
description?: string;
}
// Bad
interface Props {
name: string;
description: string | undefined;
}Use T[] for simple arrays, Array<T> for complex types:
// Good
const numbers: number[] = [1, 2, 3];
const courses: Course[] = [];
// Use Array<T> for readability with complex types
const callbacks: Array<(data: unknown) => void> = [];
const tuples: Array<[string, number]> = [];Avoid type assertions. Use type guards instead:
// Good
function isCourse(obj: unknown): obj is Course {
return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj;
}
if (isCourse(data)) {
console.log(data.name);
}
// Bad
const course = data as Course;
console.log(course.name);Use descriptive names for generic type parameters:
// Good
function useState<TState>(initial: TState): [TState, (value: TState) => void] {}
function map<TInput, TOutput>(items: TInput[], fn: (item: TInput) => TOutput): TOutput[] {}
// Acceptable for simple cases
function identity<T>(value: T): T {}
// Bad
function map<A, B>(items: A[], fn: (item: A) => B): B[] {}Use function declarations for top-level functions:
// Good
export function getCourseById(id: number): Course | undefined {
return courses.find(c => c.id === id);
}
// Bad
export const getCourseById = (id: number): Course | undefined => {
return courses.find(c => c.id === id);
};Use arrow functions for callbacks and short functions:
// Good
const doubled = numbers.map(n => n * 2);
const filtered = courses.filter(c => c.status === 'OPEN');
const handleClick = useCallback((event: MouseEvent) => {
console.log('Clicked');
}, []);
// Use function declarations for named functions
function processData(data: string) {
return data.trim();
}Prefer async/await over promise chains:
// Good
async function fetchCourse(id: number): Promise<Course> {
const response = await fetch(`/api/courses/${id}`);
const data = await response.json();
return data;
}
// Bad
function fetchCourse(id: number): Promise<Course> {
return fetch(`/api/courses/${id}`)
.then(response => response.json())
.then(data => data);
}Use default parameters instead of checking for undefined:
// Good
function greet(name: string, greeting: string = 'Hello'): string {
return `${greeting}, ${name}!`;
}
// Bad
function greet(name: string, greeting?: string): string {
const finalGreeting = greeting || 'Hello';
return `${finalGreeting}, ${name}!`;
}Place rest parameters last:
// Good
function sum(initial: number, ...numbers: number[]): number {
return initial + numbers.reduce((a, b) => a + b, 0);
}
// Bad
function sum(...numbers: number[], initial: number): number {
return initial + numbers.reduce((a, b) => a + b, 0);
}Always use function components:
// Good
export default function Button({
variant = 'filled',
children
}: Props): JSX.Element {
return <button className={variant}>{children}</button>;
}
// Bad
const Button: React.FC<Props> = ({ variant, children }) => {
return <button className={variant}>{children}</button>;
};Define props with interfaces:
// Good
interface ButtonProps {
variant?: 'filled' | 'outline';
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
}
export function Button({
variant = 'filled',
onClick,
disabled,
children
}: React.PropsWithChildren<ButtonProps>): JSX.Element {
return <button onClick={onClick} disabled={disabled}>{children}</button>;
}Custom hooks must start with use:
// Good
export function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}Prefix event handlers with handle:
// Good
const handleClick = () => {};
const handleSubmit = (event: FormEvent) => {};
const handleChange = (value: string) => {};
// Bad
const onClick = () => {};
const submit = () => {};
const changed = () => {};Use self-closing tags when there are no children:
// Good
<Button variant="filled" />
<Input type="text" value={name} />
// Bad
<Button variant="filled"></Button>Use fragments instead of div wrappers when possible:
// Good
<>
<Header />
<Main />
</>
// Bad
<div>
<Header />
<Main />
</div>Document all exported functions, classes, and complex types:
/**
* Calculates the total credit hours for a schedule.
*
* @param courses - The list of courses in the schedule
* @returns The total number of credit hours
*/
export function calculateTotalHours(courses: Course[]): number {
return courses.reduce((total, course) => total + course.creditHours, 0);
}/**
* Represents a university course.
*/
export interface Course {
/** Unique identifier for the course */
id: number;
/** Full course name including department */
name: string;
/** Number of credit hours */
creditHours: number;
}Use inline comments sparingly for complex logic:
// Good - Explains non-obvious behavior
// Remove duplicate courses based on unique ID
const uniqueCourses = courses.filter((course, index, arr) => arr.findIndex(c => c.id === course.id) === index);
// Bad - Obvious comment
// Add 1 to count
count = count + 1;Format action comments consistently:
// TODO: Implement pagination
// FIXME: Handle edge case when array is empty
// NOTE: This workaround is needed for Safari compatibilityUse CSS/SCSS modules for component styles:
// Component.tsx
import styles from './Component.module.scss';
export function Component() {
return <div className={styles.container}>Content</div>;
}// Component.module.scss
.container {
padding: 1rem;
background: white;
}Use @use instead of @import:
// Good
@use 'colors.module.scss';
.text {
color: colors.$primary;
}
// Bad
@import 'colors.module.scss';Use clsx for conditional classes:
import clsx from 'clsx';
<button
className={clsx(
'btn',
{
'btn-primary': variant === 'filled',
'btn-secondary': variant === 'outline',
'btn-disabled': disabled,
},
className
)}
>Avoid inline styles unless dynamic values are needed:
// Good - Dynamic color
<div style={{ backgroundColor: color }} />
// Bad - Static styles
<div style={{ padding: '1rem', margin: '0.5rem' }} />
// Use CSS classes instead
<div className={styles.container} />Always use type imports for type-only imports:
// Good
import type { Course } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
// Bad
import { Course } from '@shared/types/Course';Use const objects with as const instead of enums:
// Good
export const Status = {
OPEN: 'OPEN',
CLOSED: 'CLOSED',
WAITLISTED: 'WAITLISTED',
CANCELLED: 'CANCELLED',
} as const;
export type StatusType = (typeof Status)[keyof typeof Status];
// Bad
enum Status {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
}Use interface for object shapes that may be extended, type for unions, intersections, and utility types:
// Good - Interface for extendable object shapes
export interface DialogInfo {
title: ReactNode;
description?: ReactNode;
buttons?: ReactNode;
}
// Good - Type for unions and derived types
export type StatusType = (typeof Status)[keyof typeof Status];
export type SiteSupportType = (typeof SiteSupport)[keyof typeof SiteSupport];
// Good - Type for complex compositions
export type TextProps<TTag extends ElementType = 'span'> =
NonNullable<PropsOf<TTag>['className']> extends string ? AsProps<TTag, { variant?: Variant }> : never;Never use any. Use unknown or proper types:
// Good
async function importSchedule(scheduleData: unknown): Promise<void> {
if (!isValidSchedule(scheduleData)) {
throw new Error('Invalid schedule data');
}
// ...
}
function isValidSchedule(data: unknown): data is Serialized<UserSchedule> {
return typeof data === 'object' && data !== null;
}
// Bad
async function importSchedule(scheduleData: any): Promise<void> {
// ...
}Always use function components with TypeScript:
// Good
export default function Button({
variant = 'filled',
size = 'regular',
color,
children,
}: React.PropsWithChildren<Props>): JSX.Element {
return <button>{children}</button>;
}
// Bad
const Button = (props) => {
return <button>{props.children}</button>;
};Define props interfaces clearly with JSDoc comments:
interface Props {
/** The variant style of the button */
variant?: 'filled' | 'outline' | 'minimal';
/** The size of the button */
size?: 'regular' | 'small' | 'mini';
/** The theme color for the button */
color: ThemeColor;
/** Additional CSS classes */
className?: string;
/** Click handler */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}import type { Icon } from '@phosphor-icons/react';
import type { ThemeColor } from '@shared/types/ThemeColors';
import clsx from 'clsx';
import React from 'react';
interface Props {
color: ThemeColor;
variant?: 'filled' | 'outline';
}
/**
* A reusable button component that follows the design system.
*
* @param props - The button props
* @returns The rendered button component
*/
export function Button({
variant = 'filled',
color,
children
}: React.PropsWithChildren<Props>): JSX.Element {
return (
<button className={clsx('btn', { 'btn-filled': variant === 'filled' })}>
{children}
</button>
);
}Custom hooks must start with use and follow React hooks rules:
// Good
export default function useSchedules(): [active: UserSchedule, schedules: UserSchedule[]] {
const [schedules, setSchedules] = useState<UserSchedule[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [activeSchedule, setActiveSchedule] = useState<UserSchedule>(errorSchedule);
useEffect(() => {
const l1 = UserScheduleStore.listen('schedules', newValue => {
setSchedules(newValue);
});
return () => UserScheduleStore.removeListener(l1);
}, []);
return [activeSchedule, schedules];
}Prefix event handlers with handle:
// Good
const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
// ...
};
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// ...
};
// Bad
const onClick = () => {
/* ... */
};
const importClick = () => {
/* ... */
};Use SCSS modules with the modern @use syntax:
// Good - colors.module.scss
$burnt_orange: #bf5700;
$charcoal: #333f48;
:export {
burnt_orange: $burnt_orange;
charcoal: $charcoal;
}// Good - Component.module.scss
@use 'src/views/styles/colors.module.scss';
@use 'src/views/styles/fonts.module.scss';
.text {
color: colors.$burnt_orange;
font-size: fonts.$medium_size;
}
// Bad
@import 'src/views/styles/colors.module.scss';Create .d.ts files for SCSS modules:
// colors.module.scss.d.ts
export interface ISassColors {
burnt_orange: string;
charcoal: string;
white: string;
}
export type Color = keyof ISassColors;
declare const colors: ISassColors;
export default colors;Use UnoCSS for utility classes alongside SCSS modules:
// Good - Combining UnoCSS with component classes
<div className={clsx(styles.card, 'flex flex-col gap-4 p-6')}>
<Text variant='h2' className='text-ut-burntorange'>
Title
</Text>
</div>Use clsx for conditional className logic:
import clsx from 'clsx';
// Good
<button
className={clsx(
'btn',
{
'bg-opacity-100 shadow-md': variant === 'filled',
'bg-opacity-0 border-current': variant === 'outline',
'h-10 px-5': size === 'regular',
'h-6 px-2': size === 'mini',
},
className
)}
>Use automatic import sorting with simple-import-sort:
// 1. External libraries
import { CalendarDots, Trash } from '@phosphor-icons/react';
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
// 2. Internal path aliases (alphabetically)
import { background } from '@shared/messages';
import { DevStore } from '@shared/storage/DevStore';
import type { Course } from '@shared/types/Course';
import { Button } from '@views/components/common/Button';
import Text from '@views/components/common/Text/Text';
import useSchedules from '@views/hooks/useSchedules';
// 3. Relative imports
import styles from './Component.module.scss';Use path aliases defined in tsconfig.json:
// Good
import { Course } from '@shared/types/Course';
import { Button } from '@views/components/common/Button';
import useSchedules from '@views/hooks/useSchedules';
// Bad
import { Course } from '../../../shared/types/Course';
import { Button } from '../../components/common/Button';Prefer named exports for utilities, default exports for components:
// Good - Components
export default function Button(props: Props): JSX.Element {
return <button />;
}
// Good - Utilities and types
export const Status = { /* ... */ } as const;
export type StatusType = (typeof Status)[keyof typeof Status];
// Good - Hooks
export default function useSchedules(): [UserSchedule, UserSchedule[]] {
// ...
}Use camelCase for variables and functions:
// Good
const activeSchedule = schedules[0];
const getCourseColors = (course: Course) => {
/* ... */
};
async function addCourse(scheduleId: string, course: Course) {
/* ... */
}
// Bad
const ActiveSchedule = schedules[0];
const get_course_colors = () => {
/* ... */
};Use UPPER_SNAKE_CASE for true constants:
// Good
const MILLISECOND = 1;
const SECOND = 1000 * MILLISECOND;
const CACHE_TTL = 1 * 60 * 60 * 1000;
const REPO_OWNER = 'Longhorn-Developers';
// Bad
const millisecond = 1;
const cacheTtl = 3600000;Use PascalCase for types and interfaces:
// Good
interface UserSchedule {
/* ... */
}
type StatusType = 'OPEN' | 'CLOSED';
type ThemeColor = 'ut-burntorange' | 'ut-blue';
// Bad
interface userSchedule {
/* ... */
}
type status_type = 'OPEN' | 'CLOSED';Use PascalCase for component names:
// Good
export default function CourseBlock({ course }: Props): JSX.Element {
/* ... */
}
export function Button({ children }: Props): JSX.Element {
/* ... */
}
// Bad
export default function courseBlock() {
/* ... */
}
export const button = () => {
/* ... */
};Use function declarations for top-level functions:
// Good
export default function getSiteSupport(url: string): SiteSupportType | null {
if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) {
return SiteSupport.COURSE_CATALOG_LIST;
}
return null;
}
// Bad
export default const getSiteSupport = (url: string) => {
// ...
};Use arrow functions for callbacks and function expressions:
// Good
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
useEffect(() => {
const listener = UserScheduleStore.listen('schedules', newValue => {
setSchedules(newValue);
});
return () => UserScheduleStore.removeListener(listener);
}, []);
// Good - Array methods
const courseNames = courses.map(course => course.courseName);
const openCourses = courses.filter(course => course.status === Status.OPEN);Prefer async/await over promise chains:
// Good
export default async function importSchedule(scheduleData: unknown): Promise<void> {
if (!isValidSchedule(scheduleData)) {
throw new Error('Invalid schedule data');
}
const schedule = new UserSchedule(scheduleData);
await createSchedule(schedule.name);
for (const course of schedule.courses) {
await addCourse(schedule.id, new Course(course), true);
}
}
// Bad
export default function importSchedule(scheduleData: unknown): Promise<void> {
return validateSchedule(scheduleData)
.then(schedule => createSchedule(schedule.name))
.then(() => /* ... */);
}Leverage TypeScript utility types:
// Good
type PartialCourse = Partial<Course>;
type ReadonlyCourse = Readonly<Course>;
type CourseKeys = keyof Course;
type CourseValues = Course[keyof Course];
// Extract type from const object
export const SiteSupport = {
COURSE_CATALOG_LIST: 'COURSE_CATALOG_LIST',
MY_UT: 'MY_UT',
} as const;
export type SiteSupportType = (typeof SiteSupport)[keyof typeof SiteSupport];Use descriptive generic type parameters:
// Good
export function useDebounce<T extends unknown[]>(
func: DebouncedCallback<T>,
delay: number = 1000
): DebouncedCallback<T> {
// ...
}
function Text<TTag extends ElementType = 'span'>({ as, variant, ...rest }: TextProps<TTag>): JSX.Element {
// ...
}
// Bad
export function useDebounce<T>(func: T, delay: number): T {
// ...
}Use type guards for runtime type checking:
// Good
function isValidSchedule(data: unknown): data is Serialized<UserSchedule> {
return typeof data === 'object' && data !== null && 'courses' in data && 'name' in data;
}
function isValidHexColor(color: string): color is HexColor {
return /^#[0-9A-F]{6}$/i.test(color);
}Use JSDoc for all exported functions, types, and components:
/**
* Adds a course to a user's schedule.
*
* @param scheduleId - The id of the schedule to add the course to
* @param course - The course to add
* @param hasColor - If the course block already has colors manually set
* @returns A promise that resolves to void
* @throws An error if the schedule is not found
*/
export default async function addCourse(scheduleId: string, course: Course, hasColor = false): Promise<void> {
// ...
}/**
* Custom hook that manages user schedules.
*
* @returns A tuple containing the active schedule and an array of all schedules
*/
export default function useSchedules(): [active: UserSchedule, schedules: UserSchedule[]] {
// ...
}Use inline comments sparingly and only for complex logic:
// Good - Explaining non-obvious logic
// Remove the next button so that we don't load the same page twice
removePaginationButtons(document);
// Bad - Obvious comment
// Set the schedules
setSchedules(newSchedules);Format TODO comments consistently:
// TODO: Implement auto-registration feature
// FIXME: Handle edge case when course has no schedule
// NOTE: This is a temporary workaround for Chrome extension API limitationUse type-safe message passing with chrome-extension-toolkit:
// Define messages
export interface UserScheduleMessages {
addCourse: (data: { scheduleId: string; course: Course }) => void;
removeSchedule: (data: { scheduleId: string }) => string | undefined;
}
// Create messenger
export const background = createMessenger<BACKGROUND_MESSAGES>('background');
// Use in components
const result = await background.addCourse({ scheduleId, course });Use typed storage wrappers:
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
// Listen to changes
const listener = UserScheduleStore.listen('schedules', newValue => {
setSchedules(newValue);
});
// Clean up
return () => UserScheduleStore.removeListener(listener);Follow the site support pattern:
export const SiteSupport = {
COURSE_CATALOG_LIST: 'COURSE_CATALOG_LIST',
COURSE_CATALOG_DETAILS: 'COURSE_CATALOG_DETAILS',
MY_UT: 'MY_UT',
} as const;
export type SiteSupportType = (typeof SiteSupport)[keyof typeof SiteSupport];
export default function getSiteSupport(url: string): SiteSupportType | null {
if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) {
return SiteSupport.COURSE_CATALOG_LIST;
}
return null;
}Wrap all injected UI with ExtensionRoot:
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
export default function InjectedButton(): JSX.Element | null {
return (
<ExtensionRoot>
<Button variant='filled' color='ut-burntorange'>
Add Course
</Button>
</ExtensionRoot>
);
}