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
- 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
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
- 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
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:
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 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:
// β 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
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 = ...
// 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>
- 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.
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
})
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.