Iridel Standards

Our Engineering Principles

Here are our the practices we try our best to adhere to when developing maintainable software.

Don't use any in TypeScript

Please use zod to parse the data and never use any.

Fail Fast & Fail Hard

Encourage early validation and explicit error handling instead of letting bugs silently propagate.

  • Always handle unexpected states explicitly.
  • Prefer exceptions, validations, or type checks rather than returning undefined or null.
  • Makes debugging easier and prevents cascading errors.
// ❌ Bad
function getUserName(user?: User) {
  return user?.name;
}

// ✅ Good
function getUserName(user: User) {
  if (!user) throw new Error("User must be provided");
  return user.name;
}

Do Not Use try-catch

Instead of sprinkling try-catch blocks throughout your code, use my try-catch utility from Theo's tryCatch() method implementation and extended to match zod's safeParse() error handling style.

  • This ensures errors are caught early and handled consistently.
  • Avoid creating a "valley of unknown errors" where the actual exception handling is buried at the bottom of a function.
  • Makes your code more predictable, composable, and easy to test.

Code extended from https://gist.github.com/t3dotgg/a486c4ae66d32bf17c09c73609dacc5b

export type OperationSuccess<T> = { data: T; error: null; success: true };
export type OperationFailure<E> = { data: null; error: E; success: false };
export type OperationResult<T, E> = OperationSuccess<T> | OperationFailure<E>;

type Operation<T> = Promise<T> | (() => T) | (() => Promise<T>);

export function tryCatch<T, E = Error>(
  operation: Promise<T>
): Promise<OperationResult<T, E>>;
export function tryCatch<T, E = Error>(
  operation: () => never
): OperationResult<never, E>;
export function tryCatch<T, E = Error>(
  operation: () => Promise<T>
): Promise<OperationResult<T, E>>;
export function tryCatch<T, E = Error>(
  operation: () => T
): OperationResult<T, E>;
/**
 * Executes a synchronous or asynchronous operation and returns its result wrapped in an `OperationResult`.
 * Handles both synchronous exceptions and promise rejections, returning a standardized result.
 *
 * @typeParam T - The type of the successful result.
 * @typeParam E - The type of the error, defaults to `Error`.
 * @param operation - The operation to execute. Can be a function or a value. If a function, it will be invoked.
 * @returns An `OperationResult` containing either the result or the error. If the operation is asynchronous, returns a Promise of `OperationResult`.
 */
export function tryCatch<T, E = Error>(
  operation: Operation<T>
): OperationResult<T, E> | Promise<OperationResult<T, E>> {
  try {
    const result = typeof operation === 'function' ? operation() : operation;

    if (isPromise(result)) {
      return Promise.resolve(result)
        .then((data) => onSuccess(data))
        .catch((error) => onFailure(error));
    }

    return onSuccess(result);
  } catch (error) {
    return onFailure<E>(error);
  }
}

const onSuccess = <T>(value: T): OperationSuccess<T> => ({
  data: value,
  error: null,
  success: true,
});

const onFailure = <E>(error: unknown): OperationFailure<E> => {
  const errorParsed = error instanceof Error ? error : new Error(String(error));
  return {
    data: null,
    error: errorParsed as E,
    success: false,
  };
};

const isPromise = <T = unknown>(value: unknown): value is Promise<T> => {
  return (
    !!value &&
    (typeof value === 'object' || typeof value === 'function') &&
    typeof (value as { then?: unknown }).then === 'function'
  );
};

❌ Traditional try-catch (Valley of Unknown Errors)

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

async function getUserTraditional(userId: string) {
  try {
    // Fetch user
    const res = await fetch(`/api/users/${userId}`);
    if (!res.ok) throw new Error('Network error');

    const user = await res.json();

    // Validate user
    const validatedUser = UserSchema.parse(user); // Could throw

    return validatedUser;
  } catch (err) {
    // You have to scroll down or look at multiple lines to see what went wrong
    console.error('Something went wrong', err);
    return null;
  }
}
  • Network errors, JSON parsing errors, and validation errors are all caught in one place.
  • Hard to tell what actually failed without reading the entire try block.
  • Hard to test or handle specific errors separately.

✅ Using tryCatch() (Error Handling Localized)

import { tryCatch } from '@/utils/try-catch';
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

// Fetch user safely
async function getUserSafe(userId: string) {
  const { data: user, error: fetchError } = await tryCatch(() =>
    fetch(`/api/users/${userId}`).then((res) => {
      if (!res.ok) throw new Error('Network error');
      return res.json();
    })
  );

  if (fetchError) {
    console.error('Failed to fetch user:', fetchError);
    return null;
  }

  // Validate user safely
  const { data: validatedUser, error: validationError } = tryCatch(() =>
    UserSchema.parse(user)
  );

  if (validationError) {
    console.error('Validation failed:', validationError);
    return null;
  }

  return validatedUser;
}

Don't Repeat Yourself (DRY)

The DRY principle means avoiding duplication of knowledge or logic in code. Repetition leads to inconsistencies, bugs, and unnecessary maintenance work.

Instead of copy-pasting values, long variable names, or logic, encapsulate them in variables, constants, functions, or shared utilities.

With zod, we don't have to define a validator, type, error handling, and schema for types separately. We can define them all using zod and treat it as a single source of truth.

❌ Bad Example: Repeating Magic Values

What if we change "administrator" to "owner" in the future? If we reused these "magic value" strings across many files, we will have to change them one-by-one, which is very error-prone.

// app.tsx

// Using the same string multiple times in different places
if (!["administrator", "users"].includes(user.role)) {
  console.log("Invalid role");
  return;
}

if (user.role === "administrator") {
  grantAdminAccess();
}

if (settings.defaultRole === "user") {
  setDefaultPermissions();
}

✅ Good Example: Creating a Zod Schema

Since the values will most likely be reused across many components, we extract them into its own dedicated schema & type.

// types/user-role-enum.type.ts
export const UserRoleEnumSchema = z.enum(["administrator", "user"], {
  message: "Your user role is invalid"
});
export type UserRoleEnum = z.infer<typeof UserRoleEnumSchema>;

Then, we can use it as a central source of truth for validation, type-safety, and constants.

// app.tsx

// We get a data validator
const { data: userRole, error } = UserRoleEnumSchema.safeParse(user.role);
if (error) {
  console.log(error.issues[0].message); // Your user role is invalid
  return;
}

// Constant usage
if (user.role === UserRoleEnumSchema.enum.administrator) {
  grantAdminAccess();
}

// Type-safety
if ((settings.defaultRole as UserRoleEnum) === "user") {
  setDefaultPermissions();
}

Keep it Simple, Stupid (KISS)

By extension of that, keep it readable.

Code in a team-setting is made to be read and built upon by other people.

  • Don’t over-engineer abstractions “just in case.”
  • Prefer straightforward solutions over clever hacks.
  • Readability > terseness.
// ❌ Bad: overcomplicated
const isEven = (n: number) => !!(!(n & 1));

// ✅ Good: simple, readable
const isEven = (n: number) => n % 2 === 0;

If complexity is needed, extract the function into a utility or abstracted function that exposes a clear API that is named appropriately for the job.

  • Since complexity increases the possibility of bugs, add test cases to ensure the main functionality of that complex solution is kept in check.

❌ Bad Example: Inline Complex Logic

// app.tsx
// Trying to calculate the next recurring date directly inside the component
const nextDate =
  new Date(
    today.getFullYear(),
    today.getMonth() + (today.getDate() > 15 ? 2 : 1),
    1
  );

This logic is hard to read, understand, and test.

✅ Good Example: Extract & Test the Utility

// features/billing/utils/get-next-billing-date.ts
/**
 * Returns the first day of the next billing cycle.
 * Billing cycles start on the 1st of the month.
 */
export function getNextBillingDate(today: Date): Date {
  const nextMonth = today.getDate() > 15 ? today.getMonth() + 2 : today.getMonth() + 1;
  return new Date(today.getFullYear(), nextMonth, 1);
}

Now, your component just calls the utility:

// app.tsx
const nextDate = getNextBillingDate(new Date());

And you can test it in isolation, preventing any regressions from occuring:

// features/billing/utils/get-next-billing-date.test.ts
import { getNextBillingDate } from "./date-utils";

it("returns next month if before 15th", () => {
  expect(getNextBillingDate(new Date("2025-08-10"))).toEqual(new Date("2025-09-01"));
});

it("skips to the month after if after 15th", () => {
  expect(getNextBillingDate(new Date("2025-08-20"))).toEqual(new Date("2025-10-01"));
});
  • test cases are colocated together with the code with the suffix *.test.*
    • ie. is-complex-code.ts will have is-complex-code.test.ts in the same directory.

Write "Self-Documenting Code"

Don't program like a competitive programmer that prioritizes programming speed. You are an engineer and need to prioritize maintainability & code onboarding speed.

The best code explains itself through clear naming, structure, and simplicity. Comments should be reserved for explaining intent, reasoning, or non-obvious context, not describing what the code already says.

  • Use descriptive names for variables, functions, and classes.
  • Avoid cryptic abbreviations and unnecessary short names.
  • Let the code show what it does; use comments to explain why it’s done this way if it's not intuitive at first.

Always think, "would I understand this code in a few months of not touching this codebase?"

❌ Bad Example: Commenting the Obvious

// Function to process orders
function process(o: any) {
  // Check if order is active
  if (o.s === 1) {
    // Calculate discount
    let d = o.t * 0.1;

    // Subtract discount from total
    let nt = o.t - d;

    // Save the order
    db.save({
      id: o.id,
      total: nt,
      status: "processed"
    });

    // Send confirmation email
    mailer.send(o.e, "Order processed successfully");
  }
}
  • Cryptic variable names (o, s, t, d, nt).
  • Comments redundantly explain what’s obvious.
  • Hard to tell why discounts are applied or what “s = 1” means.

✅ Good Example: Self-Documenting Code with Intent

interface Order {
  id: string;
  totalAmount: number;
  isActive: boolean;
  customerEmail: string;
}

const DISCOUNT_RATE = 0.1;

/**
 * Business rule: active orders receive a 10% discount.
 */
function processOrder(order: Order) {
  if (!order.isActive) return;

  const discountedTotal = applyDiscount(order.totalAmount, DISCOUNT_RATE);

  saveProcessedOrder(order.id, discountedTotal);
  notifyCustomer(order.customerEmail);
}

function applyDiscount(amount: number, rate: number): number {
  return amount - amount * rate;
}

function saveProcessedOrder(orderId: string, total: number) {
  db.save({ id: orderId, total, status: "processed" });
}

function notifyCustomer(email: string) {
  mailer.send(email, "Your order has been processed successfully.");
}
  • Clear names (processOrder, applyDiscount, discountedTotal).
  • Business rules captured in constants (DISCOUNT_RATE).
  • Code reads like English.
  • The only comment explains the business reasoning (why we apply a discount).

Colocation Over Premature Abstraction

Prefer to colocate code, styles, and tests with the feature or component they belong to instead of scattering them across the project.

  • Keep things that change together, close together.
  • Don’t prematurely abstract or move logic into “shared” or “global” files unless there’s a clear need.
  • Colocation makes it easier for new developers to understand how a feature works in one place.

You will know your code isn't colocated when you have to jump between files to understand a single component.

❌ Bad Example: Scattered Across the Project

src/
├── components/
   └── user-profile-dialog.tsx
├── styles/
   └── user-profile-dialog.module.css
├── tests/
   └── user-profile-dialog.test.ts

Here, the component, its styles, and its tests are spread out. To understand or modify UserProfile, you have to jump between multiple files & folders.

✅ Good Example: Colocated by Feature

src/
├── features/
   └── profile/
       └── user-profile-dialog/
           ├── user-profile-dialog.tsx
           ├── user-profile-dialog.module.css
           └── UserProfileDialog.test.ts

Now everything related to UserProfile is in one folder. You can open it and see the component, its styles, and tests together.

✅✅ Better Example: Use Tailwind to Colocate all UI Concerns to the TSX Component File

src/
├── features/
   └── profile/
       └── user-profile-dialog/
           ├── user-profile-dialog.tsx
           └── UserProfileDialog.test.ts

🚨 Exception: Likely Reuse Across Features

If a piece of code is very likely to be reused across multiple features, it should be extracted into a root-level folder src/utils, src/hooks, src/components, src/types before duplication happens.

This applies to:

  • Utility functions (e.g. formatDate, paginateResults).
  • Generic UI components (e.g. Modal, Button, Input).
  • Common hooks (e.g. useDebounce, useAuth).
  • Schemas or constants that are consumed across features.

❌ Bad Example: Duplicated Utility

src/
├── features/
   └── profile/
       └── user-profile-dialog/
           └── format-date.ts  // same logic later copied in another feature

✅ Good Example: Extracted at Root Level

src/
├── utils/
   └── format-date.ts
├── components/
   └── modal.tsx
├── hooks/
   └── use-debounce.hook.ts
├── features/
   └── profile/
       └── user-profile-dialog/
           └── user-profile-dialog.tsx
  • utils/ → general-purpose helper functions
  • components/ → generic UI components (not feature-specific)
  • hooks/ → reusable generic React hooks

Consistent Naming Conventions

Names should communicate intent clearly and be predictable across the codebase. A consistent naming system makes it easy for teammates to guess file paths, function purposes, and variable meanings without extra context.

  • Fileskebab-case (user-profile-dialog.tsx).
  • Components / Classes / TypesPascalCase (UserProfileDialog).
  • Hooks → Always start with use (useAuth, useDebounce).
  • ConstantsUPPER_CASE (DEFAULT_PAGE_SIZE).
  • Variables & FunctionscamelCase (userRole, formatDate).

Accessibility (a11y) First

Accessibility is not optional. Build features that are usable by everyone, including those relying on screen readers, keyboard navigation, or other assistive technologies.

  • Use semantic HTML elements (<button>, <label>, <nav>).
  • Avoid div / span when a proper semantic tag exists.
  • Ensure all interactive elements are keyboard accessible.
  • Use aria-* attributes only when necessary — prefer semantic defaults.

❌ Bad Example: Non-semantic & Inaccessible

<div onClick={submitForm}>Submit</div>

✅ Good Example: Semantic & Accessible

<button type="submit" onClick={submitForm}>Submit</button>

Rule of Thumb

  • Default: Colocate inside the feature folder.
  • Exception: If code is reused across features, move it into a root-level domain folder (utils/, components/, hooks/).
  • Don’t overthink it — colocate first, extract only when reuse is clear.