The guidelines I set in my teams to double their output

The guidelines I set in my teams to double their output

As a lead engineer, I've built tons of engineering guidelines for my past teams. It did go well.

πŸ“… April 10, 2025 (8 days ago) - πŸ“– 17 min read - πŸ‘€ 4 views

Last update: 04.10.2025

Use these guidelines in any time and your team's output will go up, bugs fewers and code will be more maintainable and readable.

(TODO: Add a "Download Markdown" button) TODO: Reformulate this: Unified Style Guide: Reduces debates on code style and guidelines by making the decision once. TODO: Reformulate this: Efficient Reviews: Simplifies PR reviews and discussion by allowing referencing guidelines that already contain examples.

TODO: Fix image sizes

Feel free to disagree on some, and only take what you like from this. If've found that this guidelines work great for teams of various sizes and experiences, but are especially useful getting a "juior-heavy" team up to speed.

General guidelines

Collaboration

Comments

  • Make heavy use of comments and JSDoc in code.
  • Provide product context: why we're implementing this feature?
  • Explain unusual technical solutions: why we're using this specific approach?

The goal is for other devs (or yourself in 6 months) to have an easier time understanding why the software was built this way.

Git

Pull Requests

  • Small and incremental PRs, they are easier to review.
  • A PR should try to accomplish a singular task or goal.

Code reviews

  • Use inclusive language, and if possible, provide suggestions.
    • "You should do this" β†’ "Let's do this."
    • "You made a mistake here" β†’ "Are we correctly handling this use case?"
    • "You have a typo" β†’ "typo: change to XYZ"
  • Suggestion: Use Conventional Comments

Style

File names

  • Default: $domain.$type.$extension (example: user.mocks.ts)
  • React components: Use PascalCase.tsx

Keep it short

Try to keep comments and all strings a variable names to the shortest (but still descriptive name):

// ❌ β€œshould” is superfluous
it('should add 1', () => {
  // ...
})
 
// βœ…
it('adds 1', () => {
  // ...
})

Colocation

βœ… Files we're likely to touch at the same time are colocated
user/
 - user.mocks.ts
 - user.test.ts
 - user.ts
...
❌ Files are scattered around the codebase
mocks/
    - user.ts
    ...
tests/
    - user.ts
    ...
src/
    - user.ts
    ...
With colocation:
You can ⌘+P `user.mo` and instantly find the right file
You can look at siblings in the tree
If you only change something `user` domain, PRs will only be 3 siblings files

Handling tech debt

  • Follow the boy scout rule. Refactor as you go when you see potential improvements that can be made to the code you're touching.
  • Challenge the existant: Just because something was built in the past doesn't mean it deserves to still be here today.
    • Be aggressive towards debt
    • Still, be pragmatic and assess with your team whether bigger reworks are worth doing right away.

Deprecating

  • Use JSDoc when deprecating something (Components, functions):
/**
 * @deprecated - Give a reason and an alternative
 **/

Your IDE will cross out all symbols marked as:

Deprecate with JSDoc
  • When deprecating a whole file, change the file name to DEPRECATED_xx

Naming

Names

  • **Booleans: ** Prefix with has or is if possible
  • **Functions: ** Prefix with verbs get, extract, filter, etc
  • **Constants: ** Use SCREAMING_SNAKE_CASE

Use positives

  • Prefer positives when naming constants to avoid double negatives
// βœ… Easy to read
if (newPushNotificationsEnabled) {
  ...
}
 
// ❌ Double negative, harder to read
if (!newPushNotificationsDisabled) {
	...
}
πŸ’‘

There can be times where we need to name constants as negatives and that’s okay, but default to positives. Be pragmatic, not dogmatic

JavaScript

Use const

// βœ… Easy to keep track of myArray's value
const myArray = [1, 2]
const myDoubledArray = myArray.map(v => v \* 2)
 
// ❌ If we keep mutating myArray it can become hard to understand what value it holds at a specific time
let myArray = [1, 2]
myArray = myArray.map(v => "completely different")
 
πŸ‘πŸ» Exception: try/catch
// βœ… Easy to set a starting value (or set to undefined)
let apiResponse = ['default-value']
 
try {
  apiResponse = await fetch('...')
} catch (error) {
  // βœ… We can even set fallback values in the catch based on error
  apiResponse = ['error-value']
}

Use named imports

❌ Default export
const myCoupon = {}
 
export default myCoupon
 
// πŸ˜₯ Variable can be renamed.
// πŸ˜₯ The `rename` function fron IDE won't rename it here
// πŸ˜₯ The `find reference` feature applied to `myCoupon` won't find this reference
import somethingElse from './coupon'
βœ… Named export
// βœ… Variable can be renamed across the codebase
// βœ… The IDE can reference the variable's use in all code
export const myCoupon = { ... }

Strict equality

In JavaScript, == is the equality operator, while === is the strict equality operator.

// βœ… Safe
const isZero = 0 === '0' // false
// === compares both value and type without converting them.
 
// ❌ Error-prone
const isZero = 0 == '0' // true
// == compares two values for equality after converting both values to a common type (type coercion)
// '0' is converted to 0 before comparison.

Exception: non-nullish comparison

// βœ… Allows to easily check if something is not nullish (undefined | null)
// While also allowing other falsy values ('', 0, false, NaN)
const hasParams = myParam != null

Early return pattern

// ❌ Nested ifs, hard to read, hard to tell what's returned
getRoute() {
	if (isEpisode) {
	  if (item.kind === "guide") {
	      return routes.getGuidePath(item.show.slug)
    } else {
      return routes.browse(item.show.slug, id)
    }
  } else if (isBlocked) {
	    return routes.getBasicBookPath(item.slug)
  } else {
    return routes.searchBookPath(item.slug)
  }
}
// βœ… Return early, reads well, no nesting
getRoute() {
	if (!isEpisode && isBlocked) {
    return routes.getBasicBookPath(item.slug)
	}
 
	if (!isEpisode) {
    return routes.searchBookPath(item.slug)
  }
 
	if (item.kind === "guide") {
    return routes.getGuidePath(item.show.slug)
	}
 
  return routes.browse(item.show.slug, id)
}

Avoid ternary operations in renders

Do not use && in returns in TSX.

If both values are not truthy, it will return the falsy value:

0 && true // 0
true && 0 // 0
false && true // false
true && '' // ''

That means that in React, we might render an unwanted value in a place where we just wanted to return nothing:

βœ… This will render '<ul></ul>'
function ContactList() {
  const contacts = []
  return (
    <ul>
      {contacts.length
        ? contacts.map((contact) => <li key={contact.id}>{contact.name}</li>)
        : null}
    </ul>
  )
}
❌ This will render '<ul>0</ul>'
function ContactList() {
  const contacts = []
  return (
    <ul>
      {contacts.length &&
        contacts.map((contact) => <li key={contact.id}>{contact.name}</li>)}
    </ul>
  )
}
πŸ’‘

See this Kent blog posts to know more

TypeScript

End to end type safety

Interface VS Type

  • Default to βœ…type over ❌interface. interfaces are can be merged together if we mistakently use the same name (declaration merging).
// Declaring a first time
type Person = {
  name: string
}
 
// Declaring a second time gives `Duplicate identifier 'Person'` error
type Person = {
  age: number
}
// Declaring a first time
interface Person {
  name: string
}
 
// Declaring a second time doesn't throw
interface Person {
  age: number
}
 
// Now both interfaces are merged
const JOHN: Person = {
  name: 'John',
  age: 26,
}
πŸ“–

Read this article for a deeper dive into the topic

⚠️

When extending a big existing type. For performance reasons, prefer interface. See why here

Immutability

Using mutable variables is error-prone. It can have nasty side effects, and TypeScript will give you a rough time. Default to working with immutable values:

Immutability using Prisma
// ❌ Don't add keys to an object after declaring it
async function findUserFromPostIds(postIds: string[]) {
  const query = {
    include: {
      post: true,
    },
  }
 
  if (postIds.length > 0) {
    query.where.postId = { in: postIds }
    // ^? ❌ TS Error: Property 'where' does not exist on type '{ include: { post: boolean; }; }'
    // πŸ“– `query` type doesn't contain a `where` key
  }
 
  return await prisma.user.findMany(query)
}
 
// ❌ Don't declare a wide type
async function findUserFromPostIds(postIds: string[]) {
  const query: Record<string, any> = {
    // ❌ This too wide of a type
    include: {
      post: true,
      anything: 'yes',
    },
  }
 
  if (postIds.length > 0) {
    // βœ… We can add entries
    query.where.postId = { in: postIds }
  }
 
  // βœ… We can use it
  return await prisma.user.findMany(query)
  //            ^? ❌ But the returned type will not include `post` because the param type wasn't narrow enough
}
 
// βœ… Use a types from the library
async function findUserFromPostIds(postIds: string[]) {
  const where = {
    include: {
      post: true,
    },
  } as const satisfies Prisma.UserWhereInput
 
  if (postIds.length > 0) {
    query.where.postId = { in: postIds }
  }
 
  return await prisma.user.findMany({
    where: where,
  })
}
 
// βœ… Use implicit type by creating immutable objects
async function findUserFromPostIds(postIds: string[]) {
  const query = {
    include: {
      post: true,
    },
    // πŸ“– Alternatively, declare another const before `query` is declared if that gets too complex
    where: postIds.length > 0 ? { postId: { in: postIds } } : undefined,
  }
 
  // βœ… Works
  return await prisma.user.findMany(query)
 
  // πŸ“– Note: You can always create new objects to add new fields, as long as it's immutable:
  return await prisma.user.findMany({
    ...query,
    include: { ...query.include, username: true },
  })
}

Use implicit types

Constants

// ❌ The return type of getUser is already `User`
// Declaring the type here complicates refactoring if the return type is changed.
const user: User = await getUser(userId)
 
// βœ…
const user = await getUser(userId)
//     ^? User

Functions

βœ… Most of the time we should mostly rely on implicit types
getUser(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
      // βœ… Return type will implicitly change based on arguments
      // If we were to remove `name: true` from the select,
      // TS would start throwing on every place that relies on `.name` anyways.
			select: { name: true, }
    });
    return user;
}
 
const name = getUser("123").name
//                          ?^ string - Type is inferred by Prisma
 
⚠️ It's fine to use the return type when we want to ensure the function's complex internal logic returns the right type.
getUser(userId: string): Promise<User> {
    const user = await sql`SELECT ...`
				// ^? any
		const superComplexCalculationWithUser = ...
		// We probably should validate at runtime here too
    return superComplexCalculationWithUser
}

The as mistake keyword

Why it's a mistake

  • Maintainability: Using as will lead to issues when refactoring our code. If the type changes, the TypeScript compiler won't be able to point out mismatches in places where as is used.
  • Type Safety: Using as, we're effectively telling the compiler to trust you that you know the type better than it can guess. This can easily lead to errors that the TypeScript would usually catch because you're bypassing its type-checking. If your assertions are incorrect, you'll experience runtime errors that will be hard to find and could have been prevented.

How do we keep types strict

There are many solutions:

  1. Use zod
function getData(unknowVar: unknown): string {
  return z.string(unknowVar)
}
πŸ’‘

You can do plenty more with Zod

  1. Use TS-reset. It overrides some of TS types for JS methods to work better

  2. Build a helper to correctly handle the type once.

Helper for Object.entries
type Entries<T> = {
  [K in keyof T]: [K, T[K]]
}[keyof T][]
 
// βœ… We can now use `getEntries` safely everywhere
export const getEntries = <T extends object>(obj: T) =>
  Object.entries(obj) as Entries<T>
  1. Build type guard functions
function assertIsString(val: any): asserts val is string {
  if (typeof val !== "string") {
    throw new Error("Not a string!");
  }
}
 
function getSomething(str: any) {
  assertIsString(str);
  // Now TypeScript knows that 'str' is a 'string'.
  return str.toUppercase();
}
 
// Other example
function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === undefined || val === null) {
    throw new Error(
      `Expected 'val' to be defined, but received ${val}`
    );
  }
}
 
 
 
#### What if I still have to use `as`?
 
- Add a comment explaining why and hints at how we could avoid using it in the future.
 
### Non-nullish assertions
 
#### The problem
 
In some instances, it is possible we know a value is here, but TS doesn't. For example we could get from Prisma:
 
```tsx title="In the args, we make sure only users with posts are returned"
const usersWithPost = this.prisma.users.findMany(...)
//      ^ User & { post?: Post} - Still type of Post is optional

It will become annoying to work with post because although we know from the filtering in args that we only got the users with posts, post type is still optional.

❌ We have to check for nullish value every time
const allPostTitles = usersWithPost.map((user) => user.post?.title).map(Boolean)
//                                                    ?^ Post | undefined

So we might naturally go with:

❌ Forcefully tell TS the value is non-nullish
const allPosts = usersWithPost.map(user => user.post! && ...)
//                                               ?^ Post

But this brings its own set of problems:

  • If we start using ! every time we think we're more right than TS, we might start using it at times we're wrong, and create hard-to-catch bugs (see the as section).
  • If we change the arguments in findMany function to also include users that don't have a post, we could run into runtime issues.

Solution

tiny-invariant is a library that check if a value is falsy, if it is, it throws. If it isn’t, it narrows the type. All in one line ❀️

const allPostTitles = usersWithPost.map((user) => {
  //                                       ?^ user.post: Post | undefined
  invariant(user.post, 'Found user without a post')
  return user.post.title
  //            ?^ Post
})

The end

I'll keep updating this post as I continue to work with my teams. Be sure to check it out from time to time.

picture of me
Written by Nathan Brachotte

I'm a Product Engineer at heart, I've helped many companies build great team culture and craft high-performance, customer-centric, well-architected apps.
✨ Always aiming for that UI & UX extra touch ✨