Last update: 04.05.2025
Working within, mentoring, and leading engineering teams over the years, I've collected and refined this set of coding guidelines.
The goal has never been to create a bunch of dogmatic rules, but rather to collaboratively agree to a way to build software that helps us work effectively, write consistent code, and ultimately ship better products faster.
They've proven valuable across teams of different sizes and experience levels, particularly in helping junior-heavy teams ramp up quickly.
Feel free to adopt what resonates with your context, adapt what needs tweaking, and discard anything that doesn't fit your use case.
Adopting a shared set of guidelines like these can:
- Reduce debates on code style and conventions by making decisions upfront.
- Simplify PR reviews and discussions by allowing easy linking to examples.
Feel free to bookmark this page, or use the "Download in Markdown" button to save it in Markdown and add it to your team's handbook.
General guidelines
Collaboration
Comments
Make heavy use of comments and JSDoc in code, to:
- 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
- No strict commit strategy, but suggested methodology
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
Code 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', () => {
// ...
})
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)
}
Keep it flat
/**
* ❌
* - Nested code
* - More test cases than needed
* - Describes add no value
* - `should` should be implied
*/
describe('goals.helpers', () => {
describe('shouldGoalSucceed', () => {
it('should return true when goal has shouldGoalSucceed set to true', () => {
expect(shouldGoalSucceed("success-goal")).toBe(true)
})
it('should return false when goal does not have shouldGoalSucceed property', () => {
expect(shouldGoalSucceed("fail-goal")).toBe(false)
})
})
describe('shouldGoalSendEmail', () => {
it('should return true when goal has shouldGoalSendEmail set to true', () => {
expect(shouldGoalSendEmail("send-email-goal")).toBe(true)
})
it('should return false when goal does not have shouldGoalSendEmail property', () => {
expect(shouldGoalSendEmail("fail-goal")).toBe(false)
})
})
}
/**
* ✅
* - Straight to the point
* - No need to test each success/failure case (when test setup is small)
* - No `describe` as it makes the code harder to read and nested
*/
it('shouldGoalSucceed - returns the right value for each goal', () => {
expect(shouldGoalSucceed('goal')).toBe(true)
expect(shouldGoalSucceed('fail-goal')).toBe(false)
})
it('shouldGoalSendEmail - returns the right value for each goal', () => {
expect(shouldGoalSendEmail('send-email-goal')).toBe(true)
expect(shouldGoalSendEmail('fail-goal')).toBe(false)
})
Colocation
- Use colocation. See this great article from Kent about it
user/
- user.mocks.ts
- user.test.ts
- user.ts
...
mocks/
- user.ts
...
tests/
- user.ts
...
src/
- user.ts
...
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:

- When deprecating a whole file, change the file name to
DEPRECATED_xx
Naming
Names
- Booleans: Prefix with
has
oris
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")
// ✅ 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
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'
// ✅ 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
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 && '' // ''
We might end up rendering an unwanted value in a place where we just wanted to return nothing:
function ContactList() {
const contacts = []
return (
<ul>
{contacts.length
? contacts.map((contact) => <li key={contact.id}>{contact.name}</li>)
: null}
</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
- In a monorepo using REST, if we can decide what tech you use: use trpc
- Using GraphQL: use Codegen and generate types (and tanstack/query hooks) automatically
- If we need to consume 3rd party REST APIs: use openapi to generate types
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 keys are expected
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:
// ❌ 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)
}
// ❌ Wide type
async function findUserFromPostIds(postIds: string[]) {
// ❌ Too wide for Prisma to understand
const query: Record<string, any> = {
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, we have to update the type in every place that uses it
*/
const user: User = await getUser(userId)
/**
* ✅
* - The return type is inferred from the function's return type
*/
const user = await getUser(userId)
// ^? User
Functions
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
getUser(userId: string): Promise<User> {
const user = await sql`SELECT ...`
// ^? any
const superComplexCalculationWithUser = ...
/**
* ✅ TypeScript will throw if the type isn't `User`
* 📖 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 whereas
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:
- Use
zod
function getData(unknowVar: unknown): string {
return z.string(unknowVar)
}
You can do plenty more with Zod
-
Use TS-reset. It overrides some of TS types for JS methods to work better
-
Build a helper to correctly handle the type once.
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>
Find more examples in my snippet collection.
- Build type guard functions (only if you can't use Zod)
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 none of these solutions work?
- Add a comment explaining why you used
as
and hints at how we could avoid using it in the future.
Using as
to test failure cases in tests is fine.
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:
const usersWithPost = prisma.users.findMany(...)
// ^ User & { post?: Post} - Still type of Post is optional
It will become annoying to work with post because although we know post
can't be undefined
, post
type is still typed as optional.
const allPostTitles = usersWithPost.map((user) => user.post?.title).map(Boolean)
// ?^ Post | undefined
So we might naturally go with:
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 theas
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
})