Skip to main content
This guide covers both enforceable rules and subjective “taste” elements that make the Documenso codebase consistent and maintainable.

General Principles

Follow these core principles when writing code for Documenso:
  • Functional over Object-Oriented: Prefer functional programming patterns over classes
  • Explicit over Implicit: Be explicit about types, return values, and error cases
  • Early Returns: Use guard clauses and early returns to reduce nesting
  • Immutability: Favor const over let; avoid mutation where possible

TypeScript Conventions

Type Definitions

// ✅ Prefer `type` over `interface`
type CreateDocumentOptions = {
  templateId: number;
  userId: number;
  recipients: Recipient[];
};

Type Imports

Use the type keyword for type-only imports:
// ✅ Use `type` keyword for type-only imports
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';

// Types in function signatures
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
  // ...
};

Extract Complex Types

// ✅ Extract inline types to named types
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
  templateRecipientId: number;
  fields: Field[];
};

const finalRecipients: FinalRecipient[] = [];

Import Organization

Imports should be organized in the following order with blank lines between groups:
// 1. React imports
import { useCallback, useEffect, useMemo } from 'react';

// 2. Third-party library imports (alphabetically)
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';

// 3. Internal package imports (from @documenso/*)
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';

// 4. Relative imports
import { getTeamById } from '../team/get-team';
import type { FindResultResponse } from './types';

Functions & Methods

Arrow Functions

Always use arrow functions for consistency:
// ✅ Always use arrow functions
export const createDocument = async ({
  userId,
  title,
}: CreateDocumentOptions) => {
  // ...
};

// ✅ Callbacks and handlers
const onSubmit = useCallback(async () => {
  // ...
}, [dependencies]);

Function Parameters

Use destructured object parameters for multiple params:
// ✅ Use destructured object parameters
export const findDocuments = async ({
  userId,
  teamId,
  status = ExtendedDocumentStatus.ALL,
  page = 1,
  perPage = 10,
}: FindDocumentsOptions) => {
  // ...
};

React Components

Component Definition

// ✅ Use const with arrow function
export const AddSignersFormPartial = ({
  documentFlow,
  recipients,
  fields,
  onSubmit,
}: AddSignersFormProps) => {
  // ...
};

Hooks

Group related hooks together with blank line separation:
const { _ } = useLingui();
const { toast } = useToast();

const { currentStep, totalSteps, previousStep } = useStep();

const form = useForm<TFormSchema>({
  resolver: zodResolver(ZFormSchema),
  defaultValues: {
    // ...
  },
});

Event Handlers

// ✅ Use arrow functions with descriptive names
const onFormSubmit = async () => {
  await form.trigger();
  // ...
};

const onFieldCopy = useCallback(
  (event?: KeyboardEvent | null) => {
    event?.preventDefault();
    // ...
  },
  [dependencies],
);

// ✅ Inline handlers for simple operations
<Button onClick={() => setOpen(false)}>Close</Button>

State Management

// ✅ Descriptive state names with auxiliary verbs
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);

// ✅ Complex state in single useState when related
const [coords, setCoords] = useState({
  x: 0,
  y: 0,
});

Error Handling

Try-Catch Blocks

// ✅ Use try-catch for operations that might fail
try {
  const document = await getDocumentById({
    documentId: Number(documentId),
    userId: user.id,
  });

  return {
    status: 200,
    body: document,
  };
} catch (err) {
  return {
    status: 404,
    body: {
      message: 'Document not found',
    },
  };
}

Throwing Errors

// ✅ Use AppError for application errors
throw new AppError(AppErrorCode.NOT_FOUND, {
  message: 'Template not found',
});

// ✅ Use descriptive error messages
if (!template) {
  throw new AppError(AppErrorCode.NOT_FOUND, {
    message: `Template with ID ${templateId} not found`,
  });
}

Error Parsing on Frontend

// ✅ Parse errors on the frontend
try {
  await updateOrganisation({ organisationId, data });
} catch (err) {
  const error = AppError.parseError(err);
  console.error(error);

  toast({
    title: t`An error occurred`,
    description: error.message,
    variant: 'destructive',
  });
}

Naming Conventions

Variables

// ✅ camelCase for variables and functions
const documentId = 123;
const onSubmit = () => {};

// ✅ Descriptive names with auxiliary verbs for booleans
const isLoading = false;
const hasError = false;
const canEdit = true;
const shouldRender = true;

// ✅ Prefix with $ for DOM elements
const $page = document.querySelector('.page');
const $inputRef = useRef<HTMLInputElement>(null);

Types and Schemas

// ✅ PascalCase for types
type CreateDocumentOptions = {
  userId: number;
};

// ✅ Prefix Zod schemas with Z
const ZCreateDocumentSchema = z.object({
  title: z.string(),
});

// ✅ Prefix type from Zod schema with T
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;

Constants

// ✅ UPPER_SNAKE_CASE for true constants
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
const MAX_FILE_SIZE = 1024 * 1024 * 5;

// ✅ camelCase for const variables that aren't "constants"
const userId = await getUserId();

Functions

// ✅ Verb-based names for functions
const createDocument = async () => {};
const findDocuments = async () => {};
const updateDocument = async () => {};
const deleteDocument = async () => {};

// ✅ On prefix for event handlers
const onSubmit = () => {};
const onClick = () => {};
const onFieldCopy = () => {};

Clarity Over Brevity

// ✅ Prefer descriptive names over abbreviations
const recipientAuthenticationOptions = {};
const documentMetadata = {};

// ✅ Common abbreviations that are widely understood
const userId = 123;
const htmlElement = document.querySelector('div');
const apiResponse = await fetch('/api');

Pattern Matching with ts-pattern

Use match from ts-pattern for complex conditionals:
import { match } from 'ts-pattern';

// ✅ Use match for complex conditionals
const result = match(status)
  .with(ExtendedDocumentStatus.DRAFT, () => ({
    status: 'draft',
  }))
  .with(ExtendedDocumentStatus.PENDING, () => ({
    status: 'pending',
  }))
  .with(ExtendedDocumentStatus.COMPLETED, () => ({
    status: 'completed',
  }))
  .exhaustive();

// ✅ Use .otherwise() for default case when not exhaustive
const value = match(type)
  .with('text', () => 'Text field')
  .with('number', () => 'Number field')
  .otherwise(() => 'Unknown field');

Database & Prisma

Query Structure

// ✅ Use select to limit returned fields
const user = await prisma.user.findFirst({
  where: { id: userId },
  select: {
    id: true,
    email: true,
    name: true,
  },
});

// ✅ Use include for relations
const document = await prisma.document.findFirst({
  where: { id: documentId },
  include: {
    recipients: true,
    fields: true,
  },
});

Transactions

// ✅ Use transactions for related operations
return await prisma.$transaction(async (tx) => {
  const document = await tx.document.create({ data });

  await tx.field.createMany({ data: fieldsData });

  await tx.documentAuditLog.create({ data: auditData });

  return document;
});

Complex Where Clauses

// ✅ Build complex where clauses separately
const whereClause: Prisma.DocumentWhereInput = {
  AND: [
    { userId: user.id },
    { deletedAt: null },
    { status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
  ],
};

const documents = await prisma.document.findMany({
  where: whereClause,
});

TRPC Patterns

Router Structure

// ✅ Destructure context and input at start
.query(async ({ input, ctx }) => {
  const { teamId } = ctx;
  const { templateId } = input;

  ctx.logger.info({
    input: { templateId },
  });

  return await getTemplateById({
    id: templateId,
    userId: ctx.user.id,
    teamId,
  });
});

Request/Response Schemas

// ✅ Name schemas clearly
const ZCreateDocumentRequestSchema = z.object({
  title: z.string(),
  recipients: z.array(ZRecipientSchema),
});

const ZCreateDocumentResponseSchema = z.object({
  documentId: z.number(),
  status: z.string(),
});
Each TRPC route should be in its own file: routers/teams/create-team.ts with an associated types file: routers/teams/create-team.types.ts

Linting and Formatting

Documenso uses ESLint and Prettier to enforce code style:
# Lint all packages
npm run lint

# Auto-fix linting issues
npm run lint:fix

# Format code with Prettier
npm run format

ESLint Configuration

The project extends @documenso/eslint-config with these custom rules:
rules: {
  '@next/next/no-img-element': 'off',
  'no-unreachable': 'error',
  'react-hooks/exhaustive-deps': 'off',
}

When in Doubt

  • Consistency: Follow the patterns you see in similar files
  • Readability: Favor code that’s easy to read over clever one-liners
  • Explicitness: Be explicit rather than implicit
  • Whitespace: Use blank lines to separate logical sections
  • Early Returns: Use guard clauses to reduce nesting
  • Functional: Prefer functional patterns over imperative ones

View Full Code Style Guide

See the complete code style guide on GitHub for more details and examples