├── .github ├── DISCUSSION_TEMPLATE │ ├── eslint-suggestion.yml │ ├── prettier-suggestion.yml │ └── typescript-suggestion.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── README.md ├── docs ├── README.md ├── decisions │ ├── 000-template.md │ ├── 001-reset.md │ ├── 002-minimal-eslint.md │ ├── 003-semver.md │ ├── 004-types-packages.md │ ├── 005-sorting-imports.md │ ├── 005-verbatim-module-syntax.md │ ├── 006-arrow-parens.md │ ├── 007-no-semi.md │ ├── 008-new-ts-eslint-rules.md │ ├── 009-consistent-filename-casing.md │ └── README.md └── style-guide.md ├── eslint.config.js ├── eslint.js ├── fixture ├── README.md ├── app │ └── components │ │ ├── __tests__ │ │ └── accordion.tsx │ │ ├── accordion.spec.tsx │ │ ├── accordion.tsx │ │ └── swizzle.jsx ├── index.js ├── react.tsx └── tests │ └── smoke.e2e.ts ├── index.js ├── package-lock.json ├── package.json ├── prettier.js ├── reset.d.ts ├── tsconfig.json └── typescript.json /.github/DISCUSSION_TEMPLATE/eslint-suggestion.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: |- 5 | Thank you for helping to improve `@epic-web/config`! 6 | 7 | Please fill out the details below. Keep in mind that every rule added to the linting config: 8 | 9 | 1. Helps catch potential issues 10 | 2. Slows down running ESLint 11 | 3. Increases the number of annoying false positives 12 | 13 | With that in mind, please consider the following guiding principles which will be used to evaluate your suggestion: 14 | 15 | 1. The rule must catch issues that are likely to happen 16 | 2. The rule must prevent actual issues (not subjective choices) 17 | 18 | You can always extend the built-in configuration by adding your own rules in your project. The ones in the built-in config need to balance the benefit and the cost of adding a new rule. 19 | - type: textarea 20 | attributes: 21 | label: Search Terms Used 22 | description: >- 23 | Please list the search terms you used to search the discussions, issues, 24 | and PRs before submitting this issue. 25 | validations: 26 | required: true 27 | - type: input 28 | attributes: 29 | label: Rule Docs 30 | description: >- 31 | Link to the documentation for the rule you're suggesting changing 32 | placeholder: https://eslint.org/docs/latest/rules/no-var 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Suggested Change 38 | description: >- 39 | Write out the suggested change in configuration. If the rule is already 40 | enabled, explain why it should be disabled or changed. If it's not 41 | enabled, explain why it should be enabled. And provide the final 42 | configuration for the rule (if it has any additional options). 43 | validations: 44 | required: true 45 | - type: textarea 46 | attributes: 47 | label: Issue Likelihood 48 | description: >- 49 | Convince me that the rule catches issues that engineers will likely 50 | encounter. 51 | validations: 52 | required: true 53 | - type: textarea 54 | attributes: 55 | label: Issue Severity 56 | description: Convince me that the rule prevents actual issues. 57 | validations: 58 | required: true 59 | - type: textarea 60 | attributes: 61 | label: Additional Context 62 | description: Any other context that could be helpful. 63 | validations: 64 | required: false 65 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/prettier-suggestion.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: |- 5 | Thank you for helping to improve `@epic-web/config`! 6 | 7 | Please fill out the details below. 8 | 9 | Keep in mind you can always extend the built-in configuration with your own config. 10 | - type: textarea 11 | attributes: 12 | label: Search Terms Used 13 | description: >- 14 | Please list the search terms you used to search the discussions, issues, 15 | and PRs before submitting this issue. 16 | validations: 17 | required: true 18 | - type: input 19 | attributes: 20 | label: Config Docs 21 | description: >- 22 | Link to the documentation for the config option you're suggesting 23 | changing 24 | placeholder: https://prettier.io/docs/en/options#jsx-quotes 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Suggested Change 30 | description: >- 31 | Write out the suggested change in configuration. If the config is 32 | already present, explain why it should be removed or changed. If it's 33 | not present, explain why it should be added. And provide the final 34 | configuration for the option. 35 | validations: 36 | required: true 37 | - type: textarea 38 | attributes: 39 | label: Code format before the change 40 | description: >- 41 | Show me examples of what code looks like before this change. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Code format after the change 47 | description: >- 48 | Show me examples of what code looks like after this change. 49 | validations: 50 | required: true 51 | - type: textarea 52 | attributes: 53 | label: Issues without the change 54 | description: >- 55 | Convince me why life is better with this change. 56 | validations: 57 | required: true 58 | - type: textarea 59 | attributes: 60 | label: Severity of inaction 61 | description: Convince me why not doing anything is bad. 62 | validations: 63 | required: true 64 | - type: textarea 65 | attributes: 66 | label: Additional Context 67 | description: Any other context that could be helpful. 68 | validations: 69 | required: false 70 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/typescript-suggestion.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: |- 5 | Thank you for helping to improve `@epic-web/config`! 6 | 7 | Please fill out the details below. 8 | 9 | Keep in mind you can always extend the built-in configuration with your own config. 10 | - type: textarea 11 | attributes: 12 | label: Search Terms Used 13 | description: >- 14 | Please list the search terms you used to search the discussions, issues, 15 | and PRs before submitting this issue. 16 | validations: 17 | required: true 18 | - type: input 19 | attributes: 20 | label: Config Docs 21 | description: >- 22 | Link to the documentation for the config option you're suggesting 23 | changing 24 | placeholder: https://www.typescriptlang.org/tsconfig/#noImplicitThis 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Suggested Change 30 | description: >- 31 | Write out the suggested change in configuration. If the config is 32 | already present, explain why it should be removed or changed. If it's 33 | not present, explain why it should be added. And provide the final 34 | configuration for the option. 35 | validations: 36 | required: true 37 | - type: textarea 38 | attributes: 39 | label: Issues without the change 40 | description: >- 41 | Convince me why life is better with this change. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Severity of inaction 47 | description: Convince me why not doing anything is bad. 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Additional Context 53 | description: Any other context that could be helpful. 54 | validations: 55 | required: false 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Something is wrong with `@epic-web/config`. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for helping to improve `@epic-web/config`! 8 | 9 | Please fill out the form below to give us an idea of what we can do 10 | better! 11 | - type: textarea 12 | id: system-info 13 | attributes: 14 | label: System Info 15 | description: Output of `npx envinfo --system --npmPackages --binaries` 16 | render: shell 17 | placeholder: System, Binaries, Browsers 18 | validations: 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Expected Behavior 23 | description: A concise description of what you expected to happen. 24 | validations: 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: Actual Behavior 29 | description: A concise description of what you're experiencing. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Additional Context 35 | description: Any other context that could help us improve. 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/epicweb-dev/config/discussions/new?category=q-a 5 | about: 6 | If you can't get something to work the way you expect, open a question in 7 | the discussions. 8 | - name: 👮 ESLint Rule Suggestion 9 | url: https://github.com/epicweb-dev/config/discussions/new?category=eslint-suggestion 10 | about: 11 | We appreciate you taking the time to improve `@epic-web/config` with your 12 | ideas, but we use the Discussions for this instead of the issues tab 🙂. 13 | - name: 💬 Epic Web Discord Server 14 | url: https://kcd.im/discord 15 | about: Interact with other Epic Web developers 16 | - name: 🍿 Epic Web Tips 17 | url: https://www.epicweb.dev/tips 18 | about: Check out tips from Epic Instructors on EpicWeb.dev 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: [push, pull_request] 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | permissions: 9 | contents: write # to be able to publish a GitHub release 10 | issues: write # to be able to comment on released issues 11 | pull-requests: write # to be able to comment on released pull requests 12 | id-token: write # to enable use of OIDC for npm provenance 13 | 14 | jobs: 15 | validate: 16 | name: 🔍 Validate 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: ⎔ Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | 27 | - name: 📥 Download deps 28 | uses: bahmutov/npm-install@v1 29 | with: 30 | useLockFile: false 31 | 32 | - name: 🔍 Validate 33 | run: npm run validate 34 | 35 | release: 36 | name: 🚀 Release 37 | needs: [validate] 38 | runs-on: ubuntu-latest 39 | if: 40 | ${{ github.repository == 'epicweb-dev/config' && 41 | contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', 42 | github.ref) && github.event_name == 'push' }} 43 | steps: 44 | - name: ⬇️ Checkout repo 45 | uses: actions/checkout@v4 46 | 47 | - name: ⎔ Setup node 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: 20 51 | 52 | - name: 📥 Download deps 53 | uses: bahmutov/npm-install@v1 54 | with: 55 | useLockFile: false 56 | 57 | - name: 🚀 Release 58 | uses: cycjimmy/semantic-release-action@v4.1.0 59 | with: 60 | semantic_version: 17 61 | branches: | 62 | [ 63 | '+([0-9])?(.{+([0-9]),x}).x', 64 | 'main', 65 | 'next', 66 | 'next-major', 67 | {name: 'beta', prerelease: true}, 68 | {name: 'alpha', prerelease: true} 69 | ] 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | NPM_CONFIG_PROVENANCE: true 73 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

👮 @epic-web/config

3 | 4 | Reasonable ESLint, Prettier, and TypeScript configs for epic web devs 5 | 6 |

7 | This makes assumptions about the way you prefer to develop software and gives you configurations that will actually help you in your development. 8 |

9 |
10 | 11 | ``` 12 | npm install @epic-web/config 13 | ``` 14 | 15 |
16 | 20 | 24 | 25 |
26 | 27 |
28 | 29 | 30 | [![Build Status][build-badge]][build] 31 | [![MIT License][license-badge]][license] 32 | [![Code of Conduct][coc-badge]][coc] 33 | 34 | 35 | ## The problem 36 | 37 | You're a professional, but you're mature enough to know that even professionals 38 | can make mistakes, and you value your time enough to not want to waste time 39 | configuring code quality tools or babysitting them. 40 | 41 | ## This solution 42 | 43 | This is a set of configurations you can use in your web projects to avoid 44 | wasting time. 45 | 46 | ## Decisions 47 | 48 | You can learn about the different decisions made for this project in 49 | [the decision docs](./docs/decisions). 50 | 51 | ## Usage 52 | 53 | Technically you configure everything yourself, but you can use the configs in 54 | this project as a starter for your projects (and in some cases you don't need to 55 | configure anything more than the defaults). 56 | 57 | ### Prettier 58 | 59 | The easiest way to use this config is in your `package.json`: 60 | 61 | ```json 62 | "prettier": "@epic-web/config/prettier" 63 | ``` 64 | 65 |
66 | Customizing Prettier 67 | 68 | If you want to customize things, you should probably just copy/paste the 69 | built-in config. But if you really want, you can override it using regular 70 | JavaScript stuff. 71 | 72 | Create a `.prettierrc.js` file in your project root with the following content: 73 | 74 | ```js 75 | import defaultConfig from '@epic-web/config/prettier' 76 | 77 | /** @type {import("prettier").Options} */ 78 | export default { 79 | ...defaultConfig, 80 | // .. your overrides here... 81 | } 82 | ``` 83 | 84 |
85 | 86 | ### TypeScript 87 | 88 | Create a `tsconfig.json` file in your project root with the following content: 89 | 90 | ```json 91 | { 92 | "extends": ["@epic-web/config/typescript"], 93 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 94 | "compilerOptions": { 95 | "paths": { 96 | "#app/*": ["./app/*"], 97 | "#tests/*": ["./tests/*"] 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | Create a `reset.d.ts` file in your project with these contents: 104 | 105 | ```typescript 106 | import '@epic-web/config/reset.d.ts' 107 | ``` 108 | 109 |
110 | Customizing TypeScript 111 | 112 | Learn more from 113 | [the TypeScript docs here](https://www.typescriptlang.org/tsconfig/#extends). 114 | 115 |
116 | 117 | ### ESLint 118 | 119 | Create a `eslint.config.js` file in your project root with the following 120 | content: 121 | 122 | ```js 123 | import { config as defaultConfig } from '@epic-web/config/eslint' 124 | 125 | /** @type {import("eslint").Linter.Config[]} */ 126 | export default [...defaultConfig] 127 | ``` 128 | 129 |
130 | Customizing ESLint 131 | 132 | Learn more from 133 | [the Eslint docs here](https://eslint.org/docs/latest/extend/shareable-configs#overriding-settings-from-shareable-configs). 134 | 135 |
136 | 137 | There are endless rules we could enable. However, we want to keep our 138 | configurations minimal and only enable rules that catch real problems (the kind 139 | that are likely to happen). This keeps our linting faster and reduces the number 140 | of false positives. 141 | 142 | ## License 143 | 144 | MIT 145 | 146 | 147 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/config/release.yml?branch=main&logo=github&style=flat-square 148 | [build]: https://github.com/epicweb-dev/config/actions?query=workflow%3Arelease 149 | [license-badge]: https://img.shields.io/badge/license-MIT%20License-blue.svg?style=flat-square 150 | [license]: https://github.com/epicweb-dev/config/blob/main/LICENSE 151 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 152 | [coc]: https://kentcdodds.com/conduct 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Epic Web Config Docs 2 | 3 | This package is pretty simple and small so the docs can be found in the root. 4 | However, there are decision documents in this folder that you may find 5 | interesting. 6 | -------------------------------------------------------------------------------- /docs/decisions/000-template.md: -------------------------------------------------------------------------------- 1 | # Title 2 | 3 | Date: YYYY-MM-DD 4 | 5 | Status: proposed | rejected | accepted | deprecated | superseded by 6 | [0005](0005-example.md) 7 | 8 | ## Context 9 | 10 | ## Decision 11 | 12 | ## Consequences 13 | -------------------------------------------------------------------------------- /docs/decisions/001-reset.md: -------------------------------------------------------------------------------- 1 | # Reset 2 | 3 | Date: 2024-05-25 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | There are some things I want "fixed" in every TypeScript project. For details 10 | and examples, check 11 | [the docs for `@total-typescript/ts-reset`](https://www.totaltypescript.com/ts-reset). 12 | 13 | In addition to what's available in the `ts-reset` package, I also want to have 14 | css variable support in the `style` prop of React elements. 15 | 16 | I want to handle that automatically in the tsconfig, but the problem is you 17 | can't disable it, so it's all or nothing and there could be situations where you 18 | wouldn't want the reset to be applied. 19 | 20 | ## Decision 21 | 22 | We'll create a `reset.d.ts` file and consumers will have to import it in their 23 | project manually. 24 | 25 | ## Consequences 26 | 27 | It's a bit of extra work, and it's an extra file we have to expose, but it 28 | strikes the best balance. 29 | -------------------------------------------------------------------------------- /docs/decisions/002-minimal-eslint.md: -------------------------------------------------------------------------------- 1 | # Minimal ESLint 2 | 3 | Date: 2024-05-25 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | There are endless ESLint rules you can enable for your project (no really, 10 | because you can make custom ones there is no end to them). Each rule you enable 11 | does three things: 12 | 13 | 1. Helps catch potential issues 14 | 2. Slows down running ESLint 15 | 3. Increases the number of annoying false positives 16 | 17 | Two of these things are costs and one is a benefit. As professional developers, 18 | we need to evaluate each rule based on whether that rule's benefit outweighs the 19 | cost. 20 | 21 | We determine this by evaluating the risk of an issue slipping through and the 22 | impact on the user. This is going to be relatively subjective for everyone. 23 | 24 | There are some rules which cover high impact issues, but are so unlikely to 25 | happen in any project that they are not worth including. For example, 26 | `no-compare-neg-zero` is protecting you from a pretty odd behavior, but the 27 | likelihood of it catching a real issue is so low it's not worth including. 28 | 29 | Another thing to consider is for TypeScript files, there are many rules which 30 | are completely redundant. For example, `no-setter-return` is a redundant rule in 31 | a TypeScript project because TypeScript will give a compiler error if you try to 32 | return from a setter. 33 | 34 | ## Decision 35 | 36 | We keep the rule set as minimal as reasonable. 37 | 38 | At the current time, we're probably over-minimal and more rules should probably 39 | be added. 40 | 41 | ## Consequences 42 | 43 | People wanting a more strict ESLint will have to add more rules themselves. This 44 | is very easy to do (especially with ESLint v9's flat config). 45 | -------------------------------------------------------------------------------- /docs/decisions/003-semver.md: -------------------------------------------------------------------------------- 1 | # Semantic Versioning 2 | 3 | Date: 2024-05-25 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | When you make a change that could break people's existing code, that should be 10 | treated as a "breaking change" which corresponds to the first number in a semver 11 | version number (called the "major version number"). 12 | 13 | For some people "breaking change" means "if it could break their build, it 14 | should be a major version bump." Unfortunately for this project, that means 15 | pretty much every change could be a breaking change. Doing things this way would 16 | not only be annoying as a project maintainer, but also it diminishes the meaning 17 | of a major version bump so if there really were an important major change we 18 | couldn't communicate that effectively. 19 | 20 | Some configurations in this project will affect the coming project's runtime 21 | code (like how TypeScript is configured), but most of it will not (like how 22 | Prettier or ESLint is configured). 23 | 24 | ## Decision 25 | 26 | Instead, in this project, we'll define breaking changes as: 27 | 28 | 1. If you have to change the way you consume the package 29 | 2. If the config changes your project's runtime 30 | 31 | ## Consequences 32 | 33 | This means most version bumps will be patch/minor version bumps. Major version 34 | bumps will happen if we change the name of a file, what the config module 35 | exports, or the minimum version of Node/TypeScript that's supported. 36 | -------------------------------------------------------------------------------- /docs/decisions/004-types-packages.md: -------------------------------------------------------------------------------- 1 | # Types Packages 2 | 3 | Date: 2024-05-25 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | Epic Web projects use Node.js and React. It would be really handy if this 10 | project included the types for these packages by default. 11 | 12 | However, doing this means the consumer doesn't get to choose the version of the 13 | types which is a major issue. 14 | 15 | ## Decision 16 | 17 | Don't include the types in dependencies. 18 | 19 | ## Consequences 20 | 21 | Consumers will have to install `@types/` packages themselves. 22 | -------------------------------------------------------------------------------- /docs/decisions/005-sorting-imports.md: -------------------------------------------------------------------------------- 1 | # Title 2 | 3 | Date: 2024-05-27 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | Import order matters. It determines the order in which modules will be 10 | evaluated. Most of the time this doesn't make an impact on the user experience. 11 | So the import order normally doesn't actually matter. 12 | 13 | Having a pre-defined way to sort imports can reduce the amount of noise in PRs, 14 | especially when people's editors handle automatic imports differently. 15 | 16 | Having the editor yell at you because the import order is not correct is super 17 | annoying, but having the editor do this automatically is nice. If it's something 18 | you don't even have to think about then it's fine. 19 | 20 | Prettier is often used for formatting. Changing the import order isn't really 21 | formatting though, so even though there is 22 | [a plugin](https://npm.im/prettier-plugin-organize-imports) to make Prettier 23 | format the import order, it has a few limitations, and it's philosophically 24 | counter to the purpose of Prettier because changing the import order technically 25 | affects the semantics of the code. 26 | 27 | ESLint on the other hand can handle this for us automatically and allows us to 28 | customize the order itself a bit better. Additionally, if you have a side effect 29 | import (like `import './foo.js'`), it doesn't enforce the import order. 30 | 31 | ## Decision 32 | 33 | Use the `eslint-plugin-import-x` plugin to sort imports. 34 | 35 | ## Consequences 36 | 37 | People who don't like the sorting will need to disable it either inline or in 38 | their own config. 39 | -------------------------------------------------------------------------------- /docs/decisions/005-verbatim-module-syntax.md: -------------------------------------------------------------------------------- 1 | # verbatimModuleSyntax 2 | 3 | Date: 2024-05-30 4 | 5 | Status: deprecated 6 | 7 | Deprecation date: 2024-05-31 8 | 9 | ## Deprecation Note 10 | 11 | Turns out in Remix that `verbatimModuleSyntax` will cause issues if you try to 12 | import a `type` from a `.server` file into a non `.server` file. Like what we do 13 | in the Epic Stack for our toast utilities: 14 | 15 | ```tsx 16 | import { useEffect } from 'react' 17 | import { toast as showToast } from 'sonner' 18 | import { type Toast } from '#app/utils/toast.server.ts' // <-- the build is very unhappy about this with verbatimModuleSyntax 19 | 20 | export function useToast(toast?: Toast | null) { 21 | useEffect(() => { 22 | if (toast) { 23 | setTimeout(() => { 24 | showToast[toast.type](toast.title, { 25 | id: toast.id, 26 | description: toast.description, 27 | }) 28 | }, 0) 29 | } 30 | }, [toast]) 31 | } 32 | ``` 33 | 34 | For that reason, this has been removed from the config. 35 | 36 | ## Context 37 | 38 | The best context for this can be gathered by reading 39 | [the TypeScript docs on `verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax). 40 | 41 | The short version of this is that it helps TypeScript (and other compilers that 42 | strip types) to know whether to keep a module import or not. 43 | 44 | The idea is: If the import is only there to import types, then it's removed. If 45 | it imports values then it is not. 46 | 47 | ## Decision 48 | 49 | Because it's more predictable behavior (and recommended by TypeScript) we will 50 | enable this rule. 51 | 52 | ## Consequences 53 | 54 | The only change people should experience with this change is a more consistent 55 | and correct behavior. It's unlikely anyone will notice this change, but if they 56 | do it will probably be because it fixed a bug. 57 | -------------------------------------------------------------------------------- /docs/decisions/006-arrow-parens.md: -------------------------------------------------------------------------------- 1 | # Arrow Parens 2 | 3 | Date: 2024-06-13 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | Prettier has a configuration option called `arrowParens` which decides whether 10 | to add parentheses around the arguments of arrow functions. The available 11 | options are: 12 | 13 | - "always" - Add parentheses around the arguments of arrow functions. 14 | - "avoid" - Only add parentheses around the arguments of arrow functions if it 15 | improves readability. 16 | 17 | The "always" option adds parentheses around the arguments of arrow functions, 18 | even if there's only one argument. This can result in unnecessary parentheses in 19 | the code. 20 | 21 | The "avoid" option removes parentheses around the arguments if there is only one 22 | argument (and that one argument is not being destructured or defaulted). This 23 | means that if the argument is a single identifier, it will be printed without 24 | parentheses. However, if the argument is a more complex expression, parentheses 25 | will be added due to syntax requirements. 26 | 27 | Just reading those descriptions demonstrates that the rules around when it's ok 28 | to avoid parentheses are more complicated than the simple rule of: "always have 29 | parentheses". 30 | 31 | Additionally, consider this: if you have a single argument in an arrow function, 32 | you will not have parentheses around it. If you then decide to destructure it, 33 | add an argument, add a type, or add a default value, you will have to add 34 | parentheses. 35 | 36 | We want to avoid the extra work required to refactor code as much as possible. 37 | Additionally, simpler rules are often better. The simple rule of "always have 38 | parentheses" around the arguments of arrow functions is much simpler. 39 | 40 | ## Decision 41 | 42 | Update the Prettier config from "avoid" to "always." 43 | 44 | ## Consequences 45 | 46 | People will need to reformat their code when they update `@epic-web/config`. In 47 | accordance to our [semver policy](./003-semver.md), we will not be treating this 48 | as a major version bump. 49 | -------------------------------------------------------------------------------- /docs/decisions/007-no-semi.md: -------------------------------------------------------------------------------- 1 | # No Semicolons 2 | 3 | Date: 2024-06-14 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | First off, I want to call out that by not using semicolons, we are not relying 10 | on 11 | [automatic semicolon insertion](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#automatic_semicolon_insertion). 12 | We have build tools and things that are going to compile our code and minify it 13 | and everything, they'll add the semicolons for us automatically. 14 | 15 | Another issue people have with leaving off semicolons is you can start a line 16 | with a bracket or a parentheses and that can cause problems if the previous line 17 | doesn't have a semicolon. We're not going to have those problems because we use 18 | the 19 | [`no-unexpected-multiline`](https://eslint.org/docs/latest/rules/no-unexpected-multiline) 20 | rule from ESLint (not to mention Prettier makes the code look funny if you try 21 | that). For example, if you were to write something like this: 22 | 23 | 24 | ```js 25 | let firstPerson 26 | const people = [ 27 | { id: 1, name: 'Bob', age: 8 }, 28 | { id: 2, name: 'Alice', age: 11 }, 29 | { id: 3, name: 'Charlie', age: 15 }, 30 | { id: 4, name: 'Dave', age: 7 }, 31 | { id: 5, name: 'Eve', age: 13 } 32 | ] 33 | [firstPerson] = people 34 | ``` 35 | 36 | Prettier would rewrite it to look like this: 37 | 38 | ```js 39 | let firstPerson 40 | const people = ([ 41 | { id: 1, name: 'Bob', age: 8 }, 42 | { id: 2, name: 'Alice', age: 11 }, 43 | { id: 3, name: 'Charlie', age: 15 }, 44 | { id: 4, name: 'Dave', age: 7 }, 45 | { id: 5, name: 'Eve', age: 13 }, 46 | ][firstPerson] = people) 47 | ``` 48 | 49 | Which makes it much more obvious something weird is happening. This is just a 50 | non-issue. 51 | 52 | Sure, ok, so the problems aren't really problems. Great. But why turn off 53 | semicolons? Turning off semicolons makes the process of refactoring our code 54 | easier by not having to babysit the semicolons. For example: 55 | 56 | 57 | ```js 58 | const people = [ 59 | { id: 1, name: 'Bob', age: 8 }, 60 | { id: 2, name: 'Alice', age: 11 }, 61 | { id: 3, name: 'Charlie', age: 15 }, 62 | { id: 4, name: 'Dave', age: 7 }, 63 | { id: 5, name: 'Eve', age: 13 }, 64 | ]; 65 | 66 | const olderThanTenAges = people 67 | .map((person) => person.age) 68 | .filter((age) => age > 10); 69 | ``` 70 | 71 | Notice that the final chained operation has a semicolon. If I decided to do the 72 | filter before the map I have to first remove the semicolon, then move the line. 73 | I call this "semicolon babysitting". However, if I don't have semicolons then I 74 | simply move the line. 75 | 76 | It's small, but it's also just one less thing to worry about when refactoring 77 | code, and it shows up in enough situations like this one and others that it's 78 | enough reason to set `semi` to `false`. 79 | 80 | ## Decision 81 | 82 | Set the Prettier config to `semi: false`. 83 | 84 | ## Consequences 85 | 86 | This is the way the config was from the beginning, so it won't affect existing 87 | users. Anyone who wants to use this config and wants to use semicolons can 88 | override that option. 89 | -------------------------------------------------------------------------------- /docs/decisions/008-new-ts-eslint-rules.md: -------------------------------------------------------------------------------- 1 | # New TS ESLint Rules 2 | 3 | Date: 2024-07-08 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | In [./002-minimal-eslint.md](002-minimal-eslint.md), it was stated: 10 | 11 | > At the current time, we're probably over-minimal and more rules should 12 | > probably be added. 13 | 14 | [@onemen](https://github.com/onemen) 15 | [created a discussion](https://github.com/epicweb-dev/config/discussions/7) to 16 | enable more rules for TypeScript. As a result, some rules were enabled. 17 | 18 | ## Decision 19 | 20 | Below is the justification for the rules being enabled: 21 | 22 | - `@typescript-eslint/no-misused-promises` - It's pretty easy to forget to add 23 | `await` to a promise when doing a `if (condition) { ... }` (or similar). 24 | - `@typescript-eslint/no-floating-promises` - It's pretty easy to forget to add 25 | `await` to a promise value. If you don't care about the return value, simply 26 | add `void` like so: `void deleteExpiredSessions()`. 27 | 28 | And here's the justification for those which will not be enabled: 29 | 30 | - `@typescript-eslint/require-await` - sometimes you really do want async 31 | without await to make a function async. TypeScript will ensure it's treated as 32 | an async function by consumers and that's enough for me. 33 | - `@typescript-eslint/prefer-promise-reject-errors` - sometimes you aren't the 34 | one creating the error, and you just want to propagate an error object with an 35 | `unknown` type. 36 | - `@typescript-eslint/only-throw-error` - same reason as above. However, this 37 | rule supports options to allow you to throw `any` and `unknown`. 38 | Unfortunately, in Remix you can throw `Response` objects, and we don't want to 39 | enable this rule for those cases. 40 | - `@typescript-eslint/no-unsafe-declaration-merging` - this is a rare enough 41 | problem (especially if you focus on types over interfaces) that it's not worth 42 | enabling. 43 | - `@typescript-eslint/no-unsafe-enum-comparison` - enums are not recommended or 44 | used in epic projects, so it's not worth enabling. 45 | - `@typescript-eslint/no-unsafe-unary-minus` - this is a rare enough problem 46 | that it's not worth enabling. 47 | - `@typescript-eslint/no-base-to-string` - this doesn't handle when your object 48 | actually does implement `toString` unless you do so with a class which is not 49 | 100% of the time. For example, the timings object in the epic stack uses 50 | `defineProperty` to implement `toString`. It's not high enough risk/impact to 51 | enable. 52 | - `@typescript-eslint/no-non-null-assertion` - normally you should not use `!` 53 | to tell TypeScript to ignore the null case, but you're a responsible adult and 54 | if you're going to do that, the linter shouldn't yell at you about it. 55 | - `@typescript-eslint/restrict-template-expressions` - `toString` is a feature 56 | of many built-in objects and custom ones. It's not worth enabling. 57 | - `@typescript-eslint/no-confusing-void-expression` - what's confusing to one 58 | person isn't necessarily confusing to others. Arrow functions that call 59 | something that returns `void` is not confusing and the types will make sure 60 | you don't mess something up. 61 | 62 | These each protect you from `any` and while it's best to avoid using `any`, it's 63 | not worth having a lint rule yell at you when you do: 64 | 65 | - `@typescript-eslint/no-unsafe-argument` 66 | - `@typescript-eslint/no-unsafe-call` 67 | - `@typescript-eslint/no-unsafe-member-access` 68 | - `@typescript-eslint/no-unsafe-return` 69 | - `@typescript-eslint/no-unsafe-assignment` 70 | 71 | ## Consequences 72 | 73 | It's possible some projects are breaking some of the rules we enable. It's 74 | unlikely that fixing those cases will pose much of a challenge. 75 | -------------------------------------------------------------------------------- /docs/decisions/009-consistent-filename-casing.md: -------------------------------------------------------------------------------- 1 | # Consistent Filename Casing (TS) 2 | 3 | Date: 2025-05-14 4 | 5 | Status: accepted 6 | 7 | ## Context 8 | 9 | TypeScript follows the case sensitivity rules of the file system it’s running on. 10 | This can be problematic if some developers are working in a case-sensitive file system and others aren’t. 11 | If a file attempts to import fileManager.ts by specifying ./FileManager.ts the file will be found 12 | in a case-insensitive file system, but not on a case-sensitive file system. 13 | 14 | When this option is set, TypeScript will issue an error if a program tries to include a file 15 | by a casing different from the casing on disk. 16 | 17 | ## Decision 18 | 19 | Set [`forceConsistentCasingInFileNames`](https://www.typescriptlang.org/tsconfig/forceConsistentCasingInFileNames.html) to true in Typescript 20 | 21 | ## Consequences 22 | 23 | Ensure seamless workflow between developers with different operating systems. -------------------------------------------------------------------------------- /docs/decisions/README.md: -------------------------------------------------------------------------------- 1 | # Decisions 2 | 3 | This directory contains all the decisions we've made for this project and serves 4 | as a record for whenever we wonder why certain decisions were made. 5 | 6 | Decisions in here are never final. But these documents should serve as a good 7 | way for someone to come up to speed on them. 8 | -------------------------------------------------------------------------------- /docs/style-guide.md: -------------------------------------------------------------------------------- 1 | # Epic Programming Style Guide 2 | 3 | This style guide is a collection of recommendations for writing code that is 4 | easy to understand, maintain, and scale. 5 | 6 | It goes hand-in-hand with the 7 | [Epic Programming Principles](https://www.epicweb.dev/principles) and the 8 | [Epic Web Config](https://github.com/epicweb-dev/config). 9 | 10 | This is an opinionated style guide that's most useful for people who: 11 | 12 | 1. Don't have a lot of experience writing code and want some guidance on how to 13 | write code that's easy to understand, maintain, and scale. 14 | 2. Have experience writing code but want a set of standards to align on for 15 | working in a team. 16 | 17 | Much of this is subjective, but most opinions are thought through and based on 18 | years of experience working with large codebases and teams. 19 | 20 | Note: Not every possible formatting opinion is mentioned because they are 21 | handled automatically by [Prettier](https://prettier.io) anyway. 22 | 23 | ## JavaScript 24 | 25 | This section will include TypeScript guidelines as well. 26 | 27 | ### Variables 28 | 29 | #### References 30 | 31 | Use `const` by default. Only use `let` when you need to reassign. Never use 32 | `var`. 33 | 34 | Remember that `const` does not mean "constant" in the sense of "unchangeable". 35 | It means "constant reference". So if the value is an object, you can still 36 | change the properties of the object. 37 | 38 | #### Naming conventions 39 | 40 | Use descriptive, clear names that explain the value's purpose. Avoid 41 | single-letter names except in small loops or reducers where the value is obvious 42 | from context. 43 | 44 | ```tsx 45 | // ✅ Good 46 | const workshopTitle = 'Web App Fundamentals' 47 | const instructorName = 'Kent C. Dodds' 48 | const isEnabled = true 49 | const sum = numbers.reduce((total, n) => total + n, 0) 50 | const names = people.map((p) => p.name) 51 | 52 | // ❌ Avoid 53 | const t = 'Web App Fundamentals' 54 | const n = 'Kent C. Dodds' 55 | const e = true 56 | ``` 57 | 58 | Follow [the naming cheatsheet](https://github.com/kettanaito/naming-cheatsheet) 59 | by [Artem Zakharchenko](https://github.com/kettanaito) for more specifics on 60 | naming conventions. 61 | 62 | #### Constants 63 | 64 | For truly constant values used across files, use uppercase with underscores: 65 | 66 | ```tsx 67 | const BASE_URL = 'https://epicweb.dev' 68 | const DEFAULT_PORT = 3000 69 | ``` 70 | 71 | ### Objects 72 | 73 | #### Literal syntax 74 | 75 | Use object literal syntax for creating objects. Use property shorthand when the 76 | property name matches the variable name. 77 | 78 | ```tsx 79 | // ✅ Good 80 | const name = 'Kent' 81 | const age = 36 82 | const person = { name, age } 83 | 84 | // ❌ Avoid 85 | const name = 'Kent' 86 | const age = 36 87 | const person = { name: name, age: age } 88 | ``` 89 | 90 | #### Computed property names 91 | 92 | Use computed property names when creating objects with dynamic property names. 93 | 94 | ```tsx 95 | // ✅ Good 96 | const key = 'name' 97 | const obj = { 98 | [key]: 'Kent', 99 | } 100 | 101 | // ❌ Avoid 102 | const key = 'name' 103 | const obj = {} 104 | obj[key] = 'Kent' 105 | ``` 106 | 107 | #### Method shorthand 108 | 109 | Use object method shorthand: 110 | 111 | ```tsx 112 | // ✅ Good 113 | const obj = { 114 | method() { 115 | // ... 116 | }, 117 | async asyncMethod() { 118 | // ... 119 | }, 120 | } 121 | 122 | // ❌ Avoid 123 | const obj = { 124 | method: function () { 125 | // ... 126 | }, 127 | asyncMethod: async function () { 128 | // ... 129 | }, 130 | } 131 | ``` 132 | 133 | > **Note**: Ordering of properties is not important (and not specified by the 134 | > spec) and it's not a priority for this style guide either. 135 | 136 | #### Accessors 137 | 138 | Don't use them. When I do this: 139 | 140 | ```ts 141 | console.log(person.name) 142 | person.name = 'Bob' 143 | ``` 144 | 145 | All I expect to happen is to get the person's name and pass it to the `log` 146 | function and to set the person's name to `'Bob'`. 147 | 148 | Once you start using property accessors (getters and setters) then those 149 | guarantees are off. 150 | 151 | ```ts 152 | // ✅ Good 153 | const person = { 154 | name: 'Hannah', 155 | } 156 | 157 | // ❌ Avoid 158 | const person = { 159 | get name() { 160 | // haha! Now I can do something more than just return the name! 😈 161 | return this.name 162 | }, 163 | set name(value) { 164 | // haha! Now I can do something more than just set the name! 😈 165 | this.name = value 166 | }, 167 | } 168 | ``` 169 | 170 | This violates the principle of least surprise. 171 | 172 | ### Arrays 173 | 174 | #### Literal syntax 175 | 176 | Use Array literal syntax for creating arrays. 177 | 178 | ```tsx 179 | // ✅ Good 180 | const items = [1, 2, 3] 181 | 182 | // ❌ Avoid 183 | const items = new Array(1, 2, 3) 184 | ``` 185 | 186 | #### Filtering falsey values 187 | 188 | Use `.filter(Boolean)` to remove falsey values from an array. 189 | 190 | ```tsx 191 | // ✅ Good 192 | const items = [1, null, 2, undefined, 3] 193 | const filteredItems = items.filter(Boolean) 194 | 195 | // ❌ Avoid 196 | const filteredItems = items.filter( 197 | (item) => item !== null && item !== undefined, 198 | ) 199 | ``` 200 | 201 | #### Array methods over loops 202 | 203 | Use Array methods over loops when transforming arrays with pure functions. Use 204 | `for` loops when imperative code is necessary. Never use `forEach` because it's 205 | never more readable than a `for` loop and there's not situation where the 206 | `forEach` callback function could be pure and useful. Prefer `for...of` over 207 | `for` loops. 208 | 209 | ```tsx 210 | // ✅ Good 211 | const items = [1, 2, 3] 212 | const doubledItems = items.map((n) => n * 2) 213 | 214 | // ❌ Avoid 215 | const doubledItems = [] 216 | for (const n of items) { 217 | doubledItems.push(n * 2) 218 | } 219 | ``` 220 | 221 | ```tsx 222 | // ✅ Good 223 | for (const n of items) { 224 | // ... 225 | } 226 | 227 | // ❌ Avoid 228 | for (let i = 0; i < items.length; i++) { 229 | const n = items[i] 230 | // ... 231 | } 232 | 233 | // ❌ Avoid 234 | items.forEach((n) => { 235 | // ... 236 | }) 237 | ``` 238 | 239 | ```tsx 240 | // ✅ Good 241 | for (const [i, n] of items.entries()) { 242 | console.log(`${n} at index ${i}`) 243 | } 244 | 245 | // ❌ Avoid 246 | for (const n of items) { 247 | const i = items.indexOf(n) 248 | console.log(`${n} at index ${i}`) 249 | } 250 | ``` 251 | 252 | #### Favor simple chains over `.reduce` 253 | 254 | Favor simple `.filter` and `.map` chains over complex `.reduce` callbacks unless 255 | performance is an issue. 256 | 257 | ```tsx 258 | // ✅ Good 259 | const items = [1, 2, 3, 4, 5] 260 | const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2) 261 | 262 | // ❌ Avoid 263 | const doubledItems = items.reduce((acc, n) => { 264 | acc.push(n * 2) 265 | return acc 266 | }, []) 267 | ``` 268 | 269 | #### Spread to copy 270 | 271 | Prefer the spread operator to copy an array: 272 | 273 | ```tsx 274 | // ✅ Good 275 | const itemsCopy = [...items] 276 | const combined = [...array1, ...array2] 277 | 278 | // ❌ Avoid 279 | const itemsCopy = items.slice() 280 | const combined = array1.concat(array2) 281 | ``` 282 | 283 | #### Non-mutative array methods 284 | 285 | Prefer non-mutative array methods like `toReversed()`, `toSorted()`, and 286 | `toSpliced()` when available. Otherwise, create a new array. Unless performance 287 | is an issue or the original array is not referenced (as in a chain of method 288 | calls). 289 | 290 | ```tsx 291 | // ✅ Good 292 | const reversedItems = items.toReversed() 293 | const mappedFilteredSortedItems = items 294 | .filter((n) => n > 2) 295 | .map((n) => n * 2) 296 | .sort((a, b) => a - b) 297 | 298 | // ❌ Avoid 299 | const reversedItems = items.reverse() 300 | ``` 301 | 302 | #### Use `with` 303 | 304 | Use `with` to create a new object with some properties replaced. 305 | 306 | ```tsx 307 | // ✅ Good 308 | const people = [{ name: 'Kent' }, { name: 'Sarah' }] 309 | const personIndex = 0 310 | const peopleWithKentReplaced = people.with(personIndex, { name: 'John' }) 311 | 312 | // ❌ Avoid (mutative) 313 | const peopleWithKentReplaced = [...people] 314 | peopleWithKentReplaced[personIndex] = { name: 'John' } 315 | ``` 316 | 317 | #### TypeScript array generic 318 | 319 | Prefer the Array generic syntax over brackets for TypeScript types: 320 | 321 | ```tsx 322 | // ✅ Good 323 | const items: Array = [] 324 | function transform(numbers: Array) {} 325 | 326 | // ❌ Avoid 327 | const items: string[] = [] 328 | function transform(numbers: number[]) {} 329 | ``` 330 | 331 | Learn more about the reasoning behind the Array generic syntax in the 332 | [Array Types in TypeScript](https://tkdodo.eu/blog/array-types-in-type-script) 333 | article by [Dominik Dorfmeister](https://github.com/tkdodo). 334 | 335 | ### Destructuring 336 | 337 | #### Destructure objects and arrays 338 | 339 | Use destructuring to make your code more terse. 340 | 341 | ```tsx 342 | // ✅ Good 343 | const { name, avatar, 𝕏: xHandle } = instructor 344 | const [first, second] = items 345 | 346 | // ❌ Avoid 347 | const name = instructor.name 348 | const avatar = instructor.avatar 349 | const xHandle = instructor.𝕏 350 | ``` 351 | 352 | Destructuring multiple levels is fine when formatted properly by a formatter, 353 | but can definitely get out of hand, so use your best judgement. As usual, try 354 | both and choose the one you hate the least. 355 | 356 | ```tsx 357 | // ✅ Good (nesting, but still readable) 358 | const { 359 | name, 360 | avatar, 361 | 𝕏: xHandle, 362 | address: [{ city, state, country }], 363 | } = instructor 364 | 365 | // ❌ Avoid (too much nesting) 366 | const [ 367 | { 368 | name, 369 | avatar, 370 | 𝕏: xHandle, 371 | address: [ 372 | { 373 | city: { 374 | latitude: firstCityLatitude, 375 | longitude: firstCityLongitude, 376 | label: firstCityLabel, 377 | }, 378 | state: { label: firstStateLabel }, 379 | country: { label: firstCountryLabel }, 380 | }, 381 | ], 382 | }, 383 | ] = instructor 384 | ``` 385 | 386 | ### Strings 387 | 388 | #### Interpolation 389 | 390 | Prefer template literals over string concatenation. 391 | 392 | ```tsx 393 | // ✅ Good 394 | const name = 'Kent' 395 | const greeting = `Hello ${name}` 396 | 397 | // ❌ Avoid 398 | const greeting = 'Hello ' + name 399 | ``` 400 | 401 | #### Multi-line strings 402 | 403 | Use template literals for multi-line strings. 404 | 405 | ```tsx 406 | // ✅ Good 407 | const html = ` 408 |
409 |

Hello

410 |
411 | `.trim() 412 | 413 | // ❌ Avoid 414 | const html = '
' + '\n' + '

Hello

' + '\n' + '
' 415 | ``` 416 | 417 | ### Functions 418 | 419 | #### Function declarations 420 | 421 | Use function declarations over function expressions. Name your functions 422 | descriptively. 423 | 424 | This is important because it allows the function definition to be hoisted to the 425 | top of the block, which means it's callable anywhere which frees your mind to 426 | think about other things. 427 | 428 | ```tsx 429 | // ✅ Good 430 | function calculateTotal(items: Array) { 431 | return items.reduce((sum, item) => sum + item, 0) 432 | } 433 | 434 | // ❌ Avoid 435 | const calculateTotal = function (items: Array) { 436 | return items.reduce((sum, item) => sum + item, 0) 437 | } 438 | 439 | const calculateTotal = (items: Array) => 440 | items.reduce((sum, item) => sum + item, 0) 441 | ``` 442 | 443 | #### Limit single-use functions 444 | 445 | Limit creating single-use functions. By taking a large function and breaking it 446 | down into many smaller functions, you reduce benefits of type inference and have 447 | to define types for each function and make additional decisions about the number 448 | and format of arguments. Instead, extract logic only when it needs to be reused 449 | or when a portion of the logic is clearly part of a unique concern. 450 | 451 | ```tsx 452 | // ✅ Good 453 | function doStuff() { 454 | // thing 1 455 | // ... 456 | // thing 2 457 | // ... 458 | // thing 3 459 | // ... 460 | // thing N 461 | } 462 | 463 | // ❌ Avoid 464 | function doThing1(param1: string, param2: number) {} 465 | function doThing2(param1: boolean, param2: User) {} 466 | function doThing3(param1: string, param2: Array, param3: User) {} 467 | function doThing4(param1: User) {} 468 | 469 | function doStuff() { 470 | doThing1() 471 | // ... 472 | doThing2() 473 | // ... 474 | doThing3() 475 | // ... 476 | doThing4() 477 | } 478 | ``` 479 | 480 | #### Default parameters 481 | 482 | Prefer default parameters over short-circuiting. 483 | 484 | ```tsx 485 | // ✅ Good 486 | function createUser(name: string, role = 'user') { 487 | return { name, role } 488 | } 489 | 490 | // ❌ Avoid 491 | function createUser(name: string, role: string) { 492 | role ??= 'user' 493 | return { name, role } 494 | } 495 | ``` 496 | 497 | #### Early return 498 | 499 | Return early to avoid deep nesting. Use guard clauses: 500 | 501 | ```tsx 502 | // ✅ Good 503 | function getMinResolutionValue(resolution: number | undefined) { 504 | if (!resolution) return undefined 505 | if (resolution <= 480) return MinResolution.noLessThan480p 506 | if (resolution <= 540) return MinResolution.noLessThan540p 507 | return MinResolution.noLessThan1080p 508 | } 509 | 510 | // ❌ Avoid 511 | function getMinResolutionValue(resolution: number | undefined) { 512 | if (resolution) { 513 | if (resolution <= 480) { 514 | return MinResolution.noLessThan480p 515 | } else if (resolution <= 540) { 516 | return MinResolution.noLessThan540p 517 | } else { 518 | return MinResolution.noLessThan1080p 519 | } 520 | } else { 521 | return undefined 522 | } 523 | } 524 | ``` 525 | 526 | #### Async/await 527 | 528 | Prefer async/await over promise chains: 529 | 530 | ```tsx 531 | // ✅ Good 532 | async function fetchUserData(userId: string) { 533 | const user = await getUser(userId) 534 | const posts = await getUserPosts(user.id) 535 | return { user, posts } 536 | } 537 | 538 | // ✅ Fine, because wrapping in try/catch is annoying 539 | function sendAnalytics(event: string) { 540 | return fetch('/api/analytics', { 541 | method: 'POST', 542 | body: JSON.stringify({ event }), 543 | }).catch(() => null) 544 | } 545 | 546 | // ❌ Avoid 547 | function fetchUserData(userId: string) { 548 | return getUser(userId).then((user) => { 549 | return getUserPosts(user.id).then((posts) => ({ user, posts })) 550 | }) 551 | } 552 | 553 | // ❌ Avoid 554 | async function sendAnalytics(event: string) { 555 | try { 556 | return await fetch('/api/analytics', { 557 | method: 'POST', 558 | body: JSON.stringify({ event }), 559 | }) 560 | } catch { 561 | // ignore 562 | return null 563 | } 564 | } 565 | ``` 566 | 567 | #### Inline Callbacks 568 | 569 | Anonymous inline callbacks should be arrow functions: 570 | 571 | ```tsx 572 | // ✅ Good 573 | const items = [1, 2, 3] 574 | const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2) 575 | 576 | // ❌ Avoid 577 | const items = [1, 2, 3] 578 | const doubledGreaterThanTwoItems = items 579 | .filter(function (n) { 580 | return n > 2 581 | }) 582 | .map(function (n) { 583 | return n * 2 584 | }) 585 | ``` 586 | 587 | #### Arrow Parens 588 | 589 | Arrow functions should include parentheses even with a single parameter: 590 | 591 | 592 | ```tsx 593 | // ✅ Good 594 | const items = [1, 2, 3] 595 | const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2) 596 | 597 | // ❌ Avoid 598 | const items = [1, 2, 3] 599 | const doubledGreaterThanTwoItems = items.filter(n => n > 2).map(n => n * 2) 600 | ``` 601 | 602 | This makes it easier to add/remove parameters without having to futz around with 603 | parentheses. 604 | 605 | ### Modules 606 | 607 | #### File Organization 608 | 609 | In general, files that change together should be located close to each other. In 610 | Breaking a single file into multiple files should be avoided unless absolutely 611 | necessary. 612 | 613 | Specifics around file structure depends on a multitude of factors: 614 | 615 | - Framework conventions 616 | - Project size 617 | - Team size 618 | 619 | Strive to keep the file structure as flat as possible. 620 | 621 | #### Module Exports 622 | 623 | Framework and other tool conventions sometimes require default exports, but 624 | prefer named exports in all other cases. 625 | 626 | ```tsx 627 | // ✅ Good 628 | export function add(a: number, b: number) { 629 | return a + b 630 | } 631 | 632 | export function subtract(a: number, b: number) { 633 | return a - b 634 | } 635 | 636 | // ❌ Avoid 637 | export default function add(a: number, b: number) { 638 | return a + b 639 | } 640 | ``` 641 | 642 | #### Barrel Files 643 | 644 | Do **not** use barrel files. If you don't know what they are, good. If you do 645 | and you like them, it's probably because you haven't experienced their issues 646 | just yet, but you will. Just avoid them. 647 | 648 | #### Pure Modules 649 | 650 | In general, strive to keep modules pure (read more about this in 651 | [Pure Modules](https://kentcdodds.com/blog/pure-modules)). This will make your 652 | application start faster and be easier to understand and test. 653 | 654 | ```tsx 655 | // ✅ Good 656 | let serverData 657 | export function init(a: number, b: number) { 658 | const el = document.getElementById('server-data') 659 | const json = el.textContent 660 | serverData = JSON.parse(json) 661 | } 662 | 663 | export function getServerData() { 664 | if (!serverData) throw new Error('Server data not initialized') 665 | return serverData 666 | } 667 | 668 | // ❌ Avoid 669 | let serverData 670 | const el = document.getElementById('server-data') 671 | const json = el.textContent 672 | export const serverData = JSON.parse(json) 673 | ``` 674 | 675 | > **Note**: In practice, you can't avoid some modules having side-effects (you 676 | > gotta kick off the app somewhere), but most modules should be pure. 677 | 678 | #### Import Conventions 679 | 680 | Import order has semantic meaning (modules are executed in the order they're 681 | imported), but if you keep most modules pure, then order shouldn't matter. For 682 | this reason, having your imports grouped can make things a bit easier to read. 683 | 684 | ```ts 685 | // Group imports in this order: 686 | import 'node:fs' // Built-in 687 | import 'match-sorter' // external packages 688 | import '#app/components' // Internal absolute imports 689 | import '../other-folder' // Internal relative imports 690 | import './local-file' // Local imports 691 | ``` 692 | 693 | #### Type Imports 694 | 695 | Each module imported should have a single import statement: 696 | 697 | ```tsx 698 | // ✅ Good 699 | import { type MatchSorterOptions, matchSorter } from 'match-sorter' 700 | 701 | // ❌ Avoid 702 | import { type MatchSorterOptions } from 'match-sorter' 703 | import { matchSorter } from 'match-sorter' 704 | ``` 705 | 706 | #### Import Location 707 | 708 | All static imports are executed at the top of the file so they should appear 709 | there as well to avoid confusion. 710 | 711 | ```tsx 712 | // ✅ Good 713 | import { matchSorter } from 'match-sorter' 714 | 715 | function doStuff() { 716 | // ... 717 | } 718 | 719 | // ❌ Avoid 720 | function doStuff() { 721 | // ... 722 | } 723 | 724 | import { matchSorter } from 'match-sorter' 725 | ``` 726 | 727 | #### Export Location 728 | 729 | All exports should be inline with the function/type/etc they are exporting. This 730 | avoids duplication of the export identifier and having to keep it updated when 731 | changing the name of the exported thing. 732 | 733 | ```tsx 734 | // ✅ Good 735 | export function add(a: number, b: number) { 736 | return a + b 737 | } 738 | 739 | // ❌ Avoid 740 | function add(a: number, b: number) { 741 | return a + b 742 | } 743 | export { add } 744 | ``` 745 | 746 | #### Module Type 747 | 748 | Use ECMAScript modules for everything. The age of CommonJS is over. 749 | 750 | ✅ Good **package.json**: 751 | 752 | ```json 753 | { 754 | "type": "module" 755 | } 756 | ``` 757 | 758 | Use **exports** field in **package.json** to explicitly declare module entry 759 | points. 760 | 761 | ✅ Good **package.json**: 762 | 763 | ```json 764 | { 765 | "exports": { 766 | "./utils": "./src/utils.js" 767 | } 768 | } 769 | ``` 770 | 771 | #### Import Aliases 772 | 773 | Use import aliases to avoid long relative paths. Use the standard `imports` 774 | config field in **package.json** to declare import aliases. 775 | 776 | ✅ Good **package.json**: 777 | 778 | ```json 779 | { 780 | "imports": { 781 | "#app/*": "./app/*", 782 | "#tests/*": "./tests/*" 783 | } 784 | } 785 | ``` 786 | 787 | ```tsx 788 | import { add } from '#app/utils/math.ts' 789 | ``` 790 | 791 | > **Note**: Latest versions of TypeScript support this syntax natively. 792 | 793 | #### Include file extensions 794 | 795 | The ECMAScript module spec requires file extensions to be included in import 796 | paths. Even though TypeScript doesn't require it, always include the file 797 | extension in your imports. An exception to this is when importing a module which 798 | has `exports` defined in its **package.json**. 799 | 800 | ```tsx 801 | // ✅ Good 802 | import { redirect } from 'react-router' 803 | import { add } from './math.ts' 804 | 805 | // ❌ Avoid 806 | import { add } from './math' 807 | ``` 808 | 809 | ### Properties 810 | 811 | #### Use dot-notation 812 | 813 | When accessing properties on objects, use dot-notation unless you can't 814 | syntactically (like if it's dynamic or uses special characters). 815 | 816 | ```tsx 817 | const user = { name: 'Brittany', 'data-id': '123' } 818 | 819 | // ✅ Good 820 | const name = user.name 821 | const id = user['data-id'] 822 | function getUserProperty(user: User, property: string) { 823 | return user[property] 824 | } 825 | 826 | // ❌ Avoid 827 | const name = user['name'] 828 | ``` 829 | 830 | ### Comparison Operators & Equality 831 | 832 | #### Triple equals 833 | 834 | Use triple equals (`===` and `!==`) for comparisons. This will ensure you're not 835 | falling prey to type coercion. 836 | 837 | That said, when comparing against `null` or `undefined`, using double equals 838 | (`==` and `!=`) is just fine. 839 | 840 | ```tsx 841 | // ✅ Good 842 | const user = { id: '123' } 843 | if (user.id === '123') { 844 | // ... 845 | } 846 | const a = null 847 | if (a === null) { 848 | // ... 849 | } 850 | if (b != null) { 851 | // ... 852 | } 853 | 854 | // ❌ Avoid 855 | if (a == null) { 856 | // ... 857 | } 858 | if (b !== null && b !== undefined) { 859 | // ... 860 | } 861 | ``` 862 | 863 | #### Rely on truthiness 864 | 865 | Rely on truthiness instead of comparison operators. 866 | 867 | ```tsx 868 | // ✅ Good 869 | if (user) { 870 | // ... 871 | } 872 | 873 | // ❌ Avoid 874 | if (user === true) { 875 | // ... 876 | } 877 | ``` 878 | 879 | #### Switch statement braces 880 | 881 | Using braces in switch statements is recommended because it helps clarify the 882 | scope of each case and it avoids variable declarations from leaking into other 883 | cases. 884 | 885 | ```tsx 886 | // ✅ Good 887 | switch (action.type) { 888 | case 'add': { 889 | const { amount } = action 890 | add(amount) 891 | break 892 | } 893 | case 'remove': { 894 | const { removal } = action 895 | remove(removal) 896 | break 897 | } 898 | } 899 | 900 | // ❌ Avoid 901 | switch (action.type) { 902 | case 'add': 903 | const { amount } = action 904 | add(amount) 905 | break 906 | case 'remove': 907 | const { removal } = action 908 | remove(removal) 909 | break 910 | } 911 | ``` 912 | 913 | #### Avoid unnecessary ternaries 914 | 915 | ```tsx 916 | // ✅ Good 917 | const isAdmin = user.role === 'admin' 918 | const value = input ?? defaultValue 919 | 920 | // ❌ Avoid 921 | const isAdmin = user.role === 'admin' ? true : false 922 | const value = input != null ? input : defaultValue 923 | ``` 924 | 925 | ### Blocks 926 | 927 | #### Use braces for multi-line blocks 928 | 929 | Use braces for multi-line blocks even when the block is the body of a single 930 | statement. 931 | 932 | ```tsx 933 | // ✅ Good 934 | if (!user) return 935 | if (user.role === 'admin') { 936 | abilities = ['add', 'remove', 'edit', 'create', 'modify', 'fly', 'sing'] 937 | } 938 | 939 | // ❌ Avoid 940 | if (user.role === 'admin') 941 | abilities = ['add', 'remove', 'edit', 'create', 'modify', 'fly', 'sing'] 942 | ``` 943 | 944 | ### Control Statements 945 | 946 | #### Use statements 947 | 948 | Unless you're using the value of the condition in an expression, prefer using 949 | statements instead of expressions. 950 | 951 | ```tsx 952 | // ✅ Good 953 | if (user) { 954 | makeUserHappy(user) 955 | } 956 | 957 | // ❌ Avoid 958 | user && makeUserHappy(user) 959 | ``` 960 | 961 | ### Comments 962 | 963 | #### Use comments to explain "why" not "what" 964 | 965 | Comments should explain why something is done a certain way, not what the code 966 | does. The names you use for variables and functions are "self-documenting" in a 967 | sense that they explain what the code does. But if you're doing something in a 968 | way that's non-obvious, comments can be helpful. 969 | 970 | ```tsx 971 | // ✅ Good 972 | // We need to sanitize lineNumber to prevent malicious use on win32 973 | // via: https://example.com/link-to-issue-or-something 974 | if (lineNumber && !(Number.isInteger(lineNumber) && lineNumber > 0)) { 975 | return { status: 'error', message: 'lineNumber must be a positive integer' } 976 | } 977 | 978 | // ❌ Avoid 979 | // Check if lineNumber is valid 980 | if (lineNumber && !(Number.isInteger(lineNumber) && lineNumber > 0)) { 981 | return { status: 'error', message: 'lineNumber must be a positive integer' } 982 | } 983 | ``` 984 | 985 | #### Use TODO comments for future improvements 986 | 987 | Use TODO comments to mark code that needs future attention or improvement. 988 | 989 | ```tsx 990 | // ✅ Good 991 | // TODO: figure out how to send error messages as JSX from here... 992 | function getErrorMessage() { 993 | // ... 994 | } 995 | 996 | // ❌ Avoid 997 | // FIXME: this is broken 998 | function getErrorMessage() { 999 | // ... 1000 | } 1001 | ``` 1002 | 1003 | #### Use FIXME comments for immediate problems 1004 | 1005 | Use FIXME comments to mark code that needs immediate attention or improvement. 1006 | 1007 | ```tsx 1008 | // ✅ Good 1009 | // FIXME: this is broken 1010 | function getErrorMessage() { 1011 | // ... 1012 | } 1013 | ``` 1014 | 1015 | > **Note**: The linter should lint against FIXME comments, so this is useful if 1016 | > you are testing things out and want to make sure you don't accidentally commit 1017 | > your work in progress. 1018 | 1019 | #### Use @ts-expect-error for TypeScript workarounds 1020 | 1021 | When you need to work around TypeScript limitations (or your own knowledge gaps 1022 | with TypeScript), use `@ts-expect-error` with a comment explaining why. 1023 | 1024 | ```tsx 1025 | // ✅ Good 1026 | // @ts-expect-error no idea why this started being an issue suddenly 🤷‍♂️ 1027 | if (jsxEl.name !== 'EpicVideo') return 1028 | 1029 | // ❌ Avoid 1030 | // @ts-ignore 1031 | if (jsxEl.name !== 'EpicVideo') return 1032 | ``` 1033 | 1034 | #### Use JSDoc for public APIs 1035 | 1036 | Use JSDoc comments for documenting public APIs and their types. 1037 | 1038 | ```tsx 1039 | // ✅ Good 1040 | /** 1041 | * This function generates a TOTP code from a configuration 1042 | * and this comment will explain a few things that are important for you to 1043 | * understand if you're using this function 1044 | * 1045 | * @param {OTPConfig} config - The configuration for the TOTP 1046 | * @returns {string} The TOTP code 1047 | */ 1048 | export function generateTOTP(config: OTPConfig) { 1049 | // ... 1050 | } 1051 | ``` 1052 | 1053 | #### Avoid redundant comments 1054 | 1055 | Don't add comments that just repeat what the code already clearly expresses. 1056 | 1057 | ```tsx 1058 | // ✅ Good 1059 | function calculateTotal(items: Array) { 1060 | return items.reduce((sum, item) => sum + item, 0) 1061 | } 1062 | 1063 | // ❌ Avoid 1064 | // This function calculates the total of all items in the array 1065 | function calculateTotal(items: Array) { 1066 | return items.reduce((sum, item) => sum + item, 0) 1067 | } 1068 | ``` 1069 | 1070 | ### Semicolons 1071 | 1072 | #### Don't use unnecessary semicolons 1073 | 1074 | Don't use semicolons. The rules for when you should use semicolons are more 1075 | complicated than the rules for when you must use semicolons. With the right 1076 | eslint rule 1077 | ([`no-unexpected-multiline`](https://eslint.org/docs/latest/rules/no-unexpected-multiline)) 1078 | and a formatter that will format your code funny for you if you mess up, you can 1079 | avoid the pitfalls. Read more about this in 1080 | [Semicolons in JavaScript: A preference](https://kentcdodds.com/blog/semicolons-in-javascript-a-preference). 1081 | 1082 | 1083 | ```tsx 1084 | // ✅ Good 1085 | const name = 'Kent' 1086 | const age = 36 1087 | const person = { name, age } 1088 | const getPersonAge = () => person.age 1089 | function getPersonName() { 1090 | return person.name 1091 | } 1092 | 1093 | // ❌ Avoid 1094 | const name = 'Kent'; 1095 | const age = 36; 1096 | const person = { name, age }; 1097 | const getPersonAge = () => person.age; 1098 | function getPersonName() { 1099 | return person.name 1100 | } 1101 | ``` 1102 | 1103 | The only time you need semicolons is when you have a statement that starts with 1104 | `(`, `[`, or `` ` ``. Instances where you do that are few and far between. You 1105 | can prefix that with a `;` if you need to and a code formatter will format your 1106 | code funny if you forget to do so (and the linter rule will bug you about it 1107 | too). 1108 | 1109 | ```tsx 1110 | // ✅ Good 1111 | const name = 'Kent' 1112 | const age = 36 1113 | const person = { name, age } 1114 | 1115 | // The formatter will add semicolons here automatically 1116 | ;(async () => { 1117 | const result = await fetch('/api/user') 1118 | return result.json() 1119 | })() 1120 | 1121 | // ❌ Avoid 1122 | const name = 'Kent' 1123 | const age = 36 1124 | const person = { name, age } 1125 | 1126 | // Don't manually add semicolons 1127 | ;(async () => { 1128 | const result = await fetch('/api/user') 1129 | return result.json() 1130 | })() 1131 | ``` 1132 | 1133 | ### Types 1134 | 1135 | #### Type Inference 1136 | 1137 | Let TypeScript do the heavy lifting with type inference when possible: 1138 | 1139 | ```ts 1140 | // ✅ Good 1141 | function add(a: number, b: number) { 1142 | return a + b // TypeScript infers return type as number 1143 | } 1144 | 1145 | // ❌ Avoid 1146 | function add(a: number, b: number): number { 1147 | return a + b 1148 | } 1149 | ``` 1150 | 1151 | #### Generics 1152 | 1153 | Use generics to create reusable components and functions. And treat type names 1154 | in generics the same way you treat any other kind of variable or parameter 1155 | (because a generic type is basically a parameter!): 1156 | 1157 | ```tsx 1158 | // ✅ Good 1159 | function createArray(length: number, value: Value): Array { 1160 | return Array(length).fill(value) 1161 | } 1162 | 1163 | // ❌ Avoid 1164 | function createStringArray(length: number, value: string) { 1165 | return Array(length).fill(value) 1166 | } 1167 | ``` 1168 | 1169 | #### Type Assertions 1170 | 1171 | Avoid type assertions (`as`) when possible. Instead, use type guards or runtime 1172 | validation. 1173 | 1174 | ```tsx 1175 | // ✅ Good 1176 | function isError(maybeError: unknown): maybeError is Error { 1177 | return ( 1178 | maybeError && 1179 | typeof maybeError === 'object' && 1180 | 'message' in maybeError && 1181 | typeof maybeError.message === 'string' 1182 | ) 1183 | } 1184 | 1185 | // ❌ Avoid 1186 | const error = caughtError as Error 1187 | ``` 1188 | 1189 | #### Type Guards 1190 | 1191 | Use type guards to narrow types and provide runtime type safety. Type guards are 1192 | functions that check if a value is of a specific type. The most common way to 1193 | create a type guard is using a type predicate. 1194 | 1195 | ```tsx 1196 | // ✅ Good - Using type predicate 1197 | function isError(maybeError: unknown): maybeError is Error { 1198 | return ( 1199 | maybeError && 1200 | typeof maybeError === 'object' && 1201 | 'message' in maybeError && 1202 | typeof maybeError.message === 'string' 1203 | ) 1204 | } 1205 | 1206 | // ✅ Good - Using type predicate with schema validation 1207 | function isApp(app: unknown): app is App { 1208 | return AppSchema.safeParse(app).success 1209 | } 1210 | 1211 | // ✅ Good - Using type predicate with composition 1212 | function isExampleApp(app: unknown): app is ExampleApp { 1213 | return isApp(app) && app.type === 'example' 1214 | } 1215 | 1216 | // ❌ Avoid - Not using type predicate 1217 | function isApp(app: unknown): boolean { 1218 | return typeof app === 'object' && app !== null 1219 | } 1220 | ``` 1221 | 1222 | Type predicates use the syntax `parameterName is Type` to tell TypeScript that 1223 | the function checks if the parameter is of the specified type. This allows 1224 | TypeScript to narrow the type in code blocks where the function returns true. 1225 | 1226 | ```tsx 1227 | // Usage example: 1228 | const maybeApp: unknown = getSomeApp() 1229 | if (isExampleApp(maybeApp)) { 1230 | // TypeScript now knows that maybeApp is definitely an ExampleApp 1231 | maybeApp.type // TypeScript knows this is 'example' 1232 | } 1233 | ``` 1234 | 1235 | #### Schema Validation 1236 | 1237 | Use schema validation (like Zod) for runtime type checking and type inference 1238 | when working with something that crosses the boundary of your codebase. 1239 | 1240 | ```tsx 1241 | // ✅ Good 1242 | const OAuthData = z.object({ 1243 | accessToken: z.string(), 1244 | refreshToken: z.string(), 1245 | expiresAt: z.date(), 1246 | }) 1247 | type OAuthData = z.infer 1248 | 1249 | const oauthData = OAuthDataSchema.parse(rawData) 1250 | 1251 | // ❌ Avoid 1252 | type OAuthData = { 1253 | accessToken: string 1254 | refreshToken: string 1255 | expiresAt: Date 1256 | } 1257 | const oauthData = rawData as OAuthData 1258 | ``` 1259 | 1260 | #### Unknown Type 1261 | 1262 | Use `unknown` instead of `any` for values of unknown type. This forces you to 1263 | perform type checking before using the value. 1264 | 1265 | ```tsx 1266 | // ✅ Good 1267 | function handleError(error: unknown) { 1268 | if (isError(error)) { 1269 | console.error(error.message) 1270 | } else { 1271 | console.error('An unknown error occurred') 1272 | } 1273 | } 1274 | 1275 | // ❌ Avoid 1276 | function handleError(error: any) { 1277 | console.error(error.message) 1278 | } 1279 | ``` 1280 | 1281 | #### Type Coercion 1282 | 1283 | Avoid implicit type coercion. Use explicit type conversion when needed. An 1284 | exception to this is working with truthiness. 1285 | 1286 | ```tsx 1287 | // ✅ Good 1288 | const number = Number(stringValue) 1289 | const string = String(numberValue) 1290 | if (user) { 1291 | // ... 1292 | } 1293 | 1294 | // ❌ Avoid 1295 | const number = +stringValue 1296 | const string = '' + numberValue 1297 | if (Boolean(user)) { 1298 | // ... 1299 | } 1300 | ``` 1301 | 1302 | ### Naming Conventions 1303 | 1304 | Learn and follow [Artem's](https://github.com/kettanaito) 1305 | [Naming Cheatsheet](https://github.com/kettanaito/naming-cheatsheet). Here's a 1306 | summary: 1307 | 1308 | ```tsx 1309 | // ✅ Good 1310 | const firstName = 'Kent' 1311 | const friends = ['Kate', 'John'] 1312 | const pageCount = 5 1313 | const hasPagination = postCount > 10 1314 | const shouldPaginate = postCount > 10 1315 | 1316 | // ❌ Avoid 1317 | const primerNombre = 'Kent' 1318 | const amis = ['Kate', 'John'] 1319 | const page_count = 5 1320 | const isPaginatable = postCount > 10 1321 | const onItmClk = () => {} 1322 | ``` 1323 | 1324 | Key principles: 1325 | 1326 | 1. Use English for all names 1327 | 2. Be consistent with naming convention (camelCase, PascalCase, etc.) 1328 | 3. Names should be Short, Intuitive, and Descriptive (S-I-D) 1329 | 4. Avoid contractions and context duplication 1330 | 5. Function names should follow the A/HC/LC pattern: 1331 | - Action (get, set, handle, etc.) 1332 | - High Context (what it operates on) 1333 | - Low Context (optional additional context) 1334 | 1335 | For example: `getUserMessages`, `handleClickOutside`, `shouldDisplayMessage` 1336 | 1337 | ### Events 1338 | 1339 | #### Event Constants 1340 | 1341 | Define event constants using a const object. Use uppercase with underscores for 1342 | event names. 1343 | 1344 | ```tsx 1345 | // ✅ Good 1346 | export const EVENTS = { 1347 | USER_CODE_RECEIVED: 'USER_CODE_RECEIVED', 1348 | AUTH_RESOLVED: 'AUTH_RESOLVED', 1349 | AUTH_REJECTED: 'AUTH_REJECTED', 1350 | } as const 1351 | 1352 | // ❌ Avoid 1353 | export const events = { 1354 | userCodeReceived: 'userCodeReceived', 1355 | authResolved: 'authResolved', 1356 | authRejected: 'authRejected', 1357 | } 1358 | ``` 1359 | 1360 | #### Event Types 1361 | 1362 | Use TypeScript to define event types based on the event constants. 1363 | 1364 | ```tsx 1365 | // ✅ Good 1366 | export type EventTypes = keyof typeof EVENTS 1367 | 1368 | // ❌ Avoid 1369 | export type EventTypes = 1370 | | 'USER_CODE_RECEIVED' 1371 | | 'AUTH_RESOLVED' 1372 | | 'AUTH_REJECTED' 1373 | ``` 1374 | 1375 | #### Event Schemas 1376 | 1377 | Define Zod schemas for event payloads to ensure type safety and runtime 1378 | validation. 1379 | 1380 | ```tsx 1381 | // ✅ Good 1382 | const CodeReceivedEventSchema = z.object({ 1383 | type: z.literal(EVENTS.USER_CODE_RECEIVED), 1384 | code: z.string(), 1385 | url: z.string(), 1386 | }) 1387 | 1388 | // ❌ Avoid 1389 | type CodeReceivedEvent = { 1390 | type: 'USER_CODE_RECEIVED' 1391 | code: string 1392 | url: string 1393 | } 1394 | ``` 1395 | 1396 | > **Note**: This is primarily useful because in event systems, you're typically 1397 | > crossing a boundary of your codebase (network etc.). 1398 | 1399 | #### Event Cleanup 1400 | 1401 | Always clean up event listeners when they're no longer needed. 1402 | 1403 | ```tsx 1404 | // ✅ Good 1405 | authEmitter.on(EVENTS.USER_CODE_RECEIVED, handleCodeReceived) 1406 | return () => { 1407 | authEmitter.off(EVENTS.USER_CODE_RECEIVED, handleCodeReceived) 1408 | } 1409 | 1410 | // ❌ Avoid 1411 | authEmitter.on(EVENTS.USER_CODE_RECEIVED, handleCodeReceived) 1412 | // No cleanup 1413 | ``` 1414 | 1415 | #### Event Error Handling 1416 | 1417 | Make certain to cover error cases and emit events for those. 1418 | 1419 | ```tsx 1420 | // ✅ Good 1421 | try { 1422 | // event handling logic 1423 | } catch (error) { 1424 | authEmitter.emit(EVENTS.AUTH_REJECTED, { 1425 | error: getErrorMessage(error), 1426 | }) 1427 | } 1428 | 1429 | // ❌ Avoid 1430 | try { 1431 | // event handling logic 1432 | } catch (error) { 1433 | console.error(error) 1434 | } 1435 | ``` 1436 | 1437 | ## React 1438 | 1439 | ### Avoid useEffect 1440 | 1441 | [You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect) 1442 | 1443 | Instead of using `useEffect`, use ref callbacks, event handlers with 1444 | `flushSync`, css, `useSyncExternalStore`, etc. 1445 | 1446 | ```tsx 1447 | // This example was ripped from the docs: 1448 | // ✅ Good 1449 | function ProductPage({ product, addToCart }) { 1450 | function buyProduct() { 1451 | addToCart(product) 1452 | showNotification(`Added ${product.name} to the shopping cart!`) 1453 | } 1454 | 1455 | function handleBuyClick() { 1456 | buyProduct() 1457 | } 1458 | 1459 | function handleCheckoutClick() { 1460 | buyProduct() 1461 | navigateTo('/checkout') 1462 | } 1463 | // ... 1464 | } 1465 | 1466 | useEffect(() => { 1467 | setCount(count + 1) 1468 | }, [count]) 1469 | 1470 | // ❌ Avoid 1471 | function ProductPage({ product, addToCart }) { 1472 | useEffect(() => { 1473 | if (product.isInCart) { 1474 | showNotification(`Added ${product.name} to the shopping cart!`) 1475 | } 1476 | }, [product]) 1477 | 1478 | function handleBuyClick() { 1479 | addToCart(product) 1480 | } 1481 | 1482 | function handleCheckoutClick() { 1483 | addToCart(product) 1484 | navigateTo('/checkout') 1485 | } 1486 | // ... 1487 | } 1488 | ``` 1489 | 1490 | There are a lot more examples in the docs. `useEffect` is not banned or 1491 | anything. There are just better ways to handle most cases. 1492 | 1493 | Here's an example of a situation where `useEffect` is appropriate: 1494 | 1495 | ```tsx 1496 | // ✅ Good 1497 | useEffect(() => { 1498 | const controller = new AbortController() 1499 | 1500 | window.addEventListener( 1501 | 'keydown', 1502 | (event: KeyboardEvent) => { 1503 | if (event.key !== 'Escape') return 1504 | 1505 | // do something based on escape key being pressed 1506 | }, 1507 | { signal: controller.signal }, 1508 | ) 1509 | 1510 | return () => { 1511 | controller.abort() 1512 | } 1513 | }, []) 1514 | ``` 1515 | 1516 | ### Don't Sync State, Derive It 1517 | 1518 | [Don't Sync State, Derive It](https://kentcdodds.com/blog/dont-sync-state-derive-it) 1519 | 1520 | ```tsx 1521 | // ✅ Good 1522 | const [count, setCount] = useState(0) 1523 | const isEven = count % 2 === 0 1524 | 1525 | // ❌ Avoid 1526 | const [count, setCount] = useState(0) 1527 | const [isEven, setIsEven] = useState(false) 1528 | 1529 | useEffect(() => { 1530 | setIsEven(count % 2 === 0) 1531 | }, [count]) 1532 | ``` 1533 | 1534 | ### Do not render falsiness 1535 | 1536 | In JSX, do not render falsy values other than `null`. 1537 | 1538 | ```tsx 1539 | // ✅ Good 1540 |
1541 | {contacts.length ?
You have {contacts.length} contacts
: null} 1542 |
1543 | 1544 | // ❌ Avoid 1545 |
1546 | {contacts.length &&
You have {contacts.length} contacts
} 1547 |
1548 | ``` 1549 | 1550 | ### Use ternaries 1551 | 1552 | Use ternaries for simple conditionals. When automatically formatted, they should 1553 | be plenty readable, even on multiple lines. Ternaries are also the only 1554 | conditional in the spec (currently) which are expressions and can be used in 1555 | return statements and other places expressions are used. 1556 | 1557 | ```tsx 1558 | // ✅ Good 1559 | const isAdmin = user.role === 'admin' 1560 | const access = isAdmin ? 'granted' : 'denied' 1561 | 1562 | function App({ user }: { user: User }) { 1563 | return ( 1564 |
1565 | {user.role === 'admin' ? Admin : null} 1566 |
1567 | ) 1568 | } 1569 | ``` 1570 | 1571 | ## Testing 1572 | 1573 | ### Test User Interactions 1574 | 1575 | Test components based on how users actually interact with them, not 1576 | implementation details: 1577 | 1578 | > The more your tests resemble the way your software is used, the more 1579 | > confidence they can give you. - 1580 | > [Kent C. Dodds](https://x.com/kentcdodds/status/977018512689455106) 1581 | 1582 | ```tsx 1583 | // ✅ Good 1584 | test('User can add items to cart', async () => { 1585 | render() 1586 | await userEvent.click(screen.getByRole('button', { name: /add to cart/i })) 1587 | await expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument() 1588 | }) 1589 | 1590 | // ❌ Avoid 1591 | test('Cart state updates when addToCart is called', () => { 1592 | const { container } = render() 1593 | const addButton = container.querySelector('[data-testid="add-button"]') 1594 | fireEvent.click(addButton) 1595 | expect( 1596 | container.querySelector('[data-testid="cart-count"]'), 1597 | ).toHaveTextContent('1') 1598 | }) 1599 | ``` 1600 | 1601 | ### Avoid Unnecessary Mocks 1602 | 1603 | Only mock what's absolutely necessary. Most of the time, you don't need to mock 1604 | any of your own code or even dependency code. 1605 | 1606 | ```tsx 1607 | // ✅ Good 1608 | function Greeting({ name }: { name: string }) { 1609 | return
Hello {name}
1610 | } 1611 | 1612 | test('Greeting displays the name', () => { 1613 | render() 1614 | expect(screen.getByText('Hello Kent')).toBeInTheDocument() 1615 | }) 1616 | 1617 | // ❌ Avoid 1618 | test('Greeting displays the name', () => { 1619 | const mockName = 'Kent' 1620 | vi.mock('./greeting.tsx', () => ({ 1621 | Greeting: () =>
Hello {mockName}
, 1622 | })) 1623 | render() 1624 | expect(container).toHaveTextContent('Hello Kent') 1625 | }) 1626 | ``` 1627 | 1628 | ### Mock External Services 1629 | 1630 | Use MSW (Mock Service Worker) to mock external services. This allows you to test 1631 | your application's integration with external APIs without actually making 1632 | network requests. 1633 | 1634 | ```tsx 1635 | // ✅ Good 1636 | import { setupServer } from 'msw/node' 1637 | import { http } from 'msw' 1638 | 1639 | const server = setupServer( 1640 | http.get('/api/user', async ({ request }) => { 1641 | return HttpResponse.json({ 1642 | name: 'Kent', 1643 | role: 'admin', 1644 | }) 1645 | }), 1646 | ) 1647 | 1648 | test('User data is fetched and displayed', async () => { 1649 | render() 1650 | await expect(await screen.findByText('Kent')).toBeInTheDocument() 1651 | }) 1652 | 1653 | // ❌ Avoid 1654 | test('User data is fetched and displayed', async () => { 1655 | vi.spyOn(global, 'fetch').mockResolvedValue({ 1656 | json: () => Promise.resolve({ name: 'Kent', role: 'admin' }), 1657 | }) 1658 | render() 1659 | await expect(await screen.findByText('Kent')).toBeInTheDocument() 1660 | }) 1661 | ``` 1662 | 1663 | ### Use Test Function 1664 | 1665 | Use the `test` function instead of `describe` and `it`. This makes tests more 1666 | straightforward and easier to understand. 1667 | 1668 | ```tsx 1669 | // ✅ Good 1670 | test('User can log in with valid credentials', async () => { 1671 | render() 1672 | await userEvent.type( 1673 | screen.getByRole('textbox', { name: /email/i }), 1674 | 'kent@example.com', 1675 | ) 1676 | await userEvent.type( 1677 | screen.getByRole('textbox', { name: /password/i }), 1678 | 'password123', 1679 | ) 1680 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1681 | await expect(await screen.findByText('Welcome back!')).toBeInTheDocument() 1682 | }) 1683 | 1684 | // ❌ Avoid 1685 | describe('LoginForm', () => { 1686 | it('should allow user to log in with valid credentials', async () => { 1687 | render() 1688 | await userEvent.type( 1689 | screen.getByRole('textbox', { name: /email/i }), 1690 | 'kent@example.com', 1691 | ) 1692 | await userEvent.type( 1693 | screen.getByRole('textbox', { name: /password/i }), 1694 | 'password123', 1695 | ) 1696 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1697 | await expect(await screen.findByText('Welcome back!')).toBeInTheDocument() 1698 | }) 1699 | }) 1700 | ``` 1701 | 1702 | ### [Avoid Nesting Tests](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) 1703 | 1704 | Keep your tests flat. Nesting makes tests harder to understand and maintain. 1705 | 1706 | ```tsx 1707 | // ✅ Good 1708 | test('User can log in', async () => { 1709 | render() 1710 | await userEvent.type( 1711 | screen.getByRole('textbox', { name: /email/i }), 1712 | 'kent@example.com', 1713 | ) 1714 | await userEvent.type( 1715 | screen.getByRole('textbox', { name: /password/i }), 1716 | 'password123', 1717 | ) 1718 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1719 | await expect(await screen.findByText('Welcome back!')).toBeInTheDocument() 1720 | }) 1721 | 1722 | // ❌ Avoid 1723 | describe('LoginForm', () => { 1724 | describe('when user enters valid credentials', () => { 1725 | it('should show welcome message', async () => { 1726 | render() 1727 | await userEvent.type( 1728 | screen.getByRole('textbox', { name: /email/i }), 1729 | 'kent@example.com', 1730 | ) 1731 | await userEvent.type( 1732 | screen.getByRole('textbox', { name: /password/i }), 1733 | 'password123', 1734 | ) 1735 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1736 | await expect(await screen.findByText('Welcome back!')).toBeInTheDocument() 1737 | }) 1738 | }) 1739 | }) 1740 | ``` 1741 | 1742 | ### Avoid shared setup/teardown variables 1743 | 1744 | ```tsx 1745 | // ✅ Good 1746 | test('renders a greeting', () => { 1747 | render() 1748 | expect(screen.getByText('Hello Kent')).toBeInTheDocument() 1749 | }) 1750 | 1751 | // ❌ Avoid 1752 | let utils 1753 | beforeEach(() => { 1754 | utils = render() 1755 | }) 1756 | 1757 | test('renders a greeting', () => { 1758 | expect(utils.getByText('Hello Kent')).toBeInTheDocument() 1759 | }) 1760 | ``` 1761 | 1762 | > **Note**: Most of the time your individual tests can avoid the use of 1763 | > `beforeEach` and `afterEach` altogether and it's only global setup that needs 1764 | > it (like mocking out `console.log` or setting up a mock server). 1765 | 1766 | ### Avoid Testing Implementation Details 1767 | 1768 | Test your components based on how they're used, not how they're implemented. 1769 | 1770 | ```tsx 1771 | // ✅ Good 1772 | function Counter() { 1773 | const [count, setCount] = useState(0) 1774 | return 1775 | } 1776 | 1777 | test('Counter increments when clicked', async () => { 1778 | render() 1779 | const button = screen.getByRole('button') 1780 | await userEvent.click(button) 1781 | expect(getByText('Count: 1')).toBeInTheDocument() 1782 | }) 1783 | 1784 | // ❌ Avoid 1785 | test('Counter increments when clicked', () => { 1786 | const { container } = render() 1787 | const button = container.querySelector('button') 1788 | fireEvent.click(button) 1789 | const state = container.querySelector('[data-testid="count"]') 1790 | expect(state).toHaveTextContent('1') 1791 | }) 1792 | ``` 1793 | 1794 | ### Keep Assertions Specific 1795 | 1796 | Make your assertions as specific as possible to catch the exact behavior you're 1797 | testing. 1798 | 1799 | ```tsx 1800 | // ✅ Good 1801 | test('Form shows error for invalid email', async () => { 1802 | render() 1803 | await userEvent.type( 1804 | screen.getByRole('textbox', { name: /email/i }), 1805 | 'invalid-email', 1806 | ) 1807 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1808 | await expect( 1809 | await screen.findByText(/enter a valid email/i), 1810 | ).toBeInTheDocument() 1811 | }) 1812 | 1813 | // ❌ Avoid 1814 | test('Form shows error for invalid email', async () => { 1815 | const { container } = render() 1816 | await userEvent.type( 1817 | screen.getByRole('textbox', { name: /email/i }), 1818 | 'invalid-email', 1819 | ) 1820 | await userEvent.click(screen.getByRole('button', { name: /login/i })) 1821 | expect(container).toMatchSnapshot() 1822 | }) 1823 | ``` 1824 | 1825 | ### Follow the Testing Trophy 1826 | 1827 | Prioritize your tests according to the Testing Trophy: 1828 | 1829 | 1. Static Analysis (TypeScript, ESLint) 1830 | 2. Unit Tests (Pure Functions) 1831 | 3. Integration Tests (Component Integration) 1832 | 4. E2E Tests (Critical User Flows) 1833 | 1834 | ```tsx 1835 | // ✅ Good 1836 | // 1. Static Analysis 1837 | function add(a: number, b: number): number { 1838 | return a + b 1839 | } 1840 | 1841 | // 2. Unit Tests 1842 | test('add function adds two numbers', () => { 1843 | expect(add(1, 2)).toBe(3) 1844 | }) 1845 | 1846 | // 3. Integration Tests 1847 | test('Calculator component adds numbers', async () => { 1848 | render() 1849 | await userEvent.click(screen.getByRole('button', { name: '1' })) 1850 | await userEvent.click(screen.getByRole('button', { name: '+' })) 1851 | await userEvent.click(screen.getByRole('button', { name: '2' })) 1852 | await userEvent.click(screen.getByRole('button', { name: '=' })) 1853 | expect(getByText('3')).toBeInTheDocument() 1854 | }) 1855 | 1856 | // 4. E2E Tests (using Playwright) 1857 | await page.goto('/calculator') 1858 | await expect(page.getByRole('button', { name: '0' })).toBeInTheDocument() 1859 | await page.getByRole('button', { name: '1' }).click() 1860 | await page.getByRole('button', { name: '+' }).click() 1861 | await page.getByRole('button', { name: '2' }).click() 1862 | await page.getByRole('button', { name: '=' }).click() 1863 | await expect(page.getByRole('button', { name: '3' })).toBeInTheDocument() 1864 | 1865 | // ❌ Avoid 1866 | // Don't write E2E tests for everything 1867 | test('every button click updates display', () => { 1868 | render() 1869 | // Testing every possible button combination... 1870 | }) 1871 | ``` 1872 | 1873 | ### Use Appropriate Queries 1874 | 1875 | Follow the query priority order and avoid using container queries: 1876 | 1877 | ```tsx 1878 | // ✅ Good 1879 | screen.getByRole('textbox', { name: /username/i }) 1880 | 1881 | // ❌ Avoid 1882 | screen.getByTestId('username') 1883 | container.querySelector('.btn-primary') 1884 | ``` 1885 | 1886 | ### Use Query Variants Correctly 1887 | 1888 | Only use query\* variants for checking non-existence: 1889 | 1890 | ```tsx 1891 | // ✅ Good 1892 | expect(screen.queryByRole('alert')).not.toBeInTheDocument() 1893 | 1894 | // ❌ Avoid 1895 | expect(screen.queryByRole('alert')).toBeInTheDocument() 1896 | ``` 1897 | 1898 | ### Use find\* Over waitFor for Elements 1899 | 1900 | Use find\* queries instead of waitFor for elements that may not be immediately 1901 | available: 1902 | 1903 | ```tsx 1904 | // ✅ Good 1905 | const submitButton = await screen.findByRole('button', { name: /submit/i }) 1906 | 1907 | // ❌ Avoid 1908 | const submitButton = await waitFor(() => 1909 | screen.getByRole('button', { name: /submit/i }), 1910 | ) 1911 | ``` 1912 | 1913 | ### Avoid Testing Implementation Details 1914 | 1915 | Test components based on how users interact with them, not implementation 1916 | details: 1917 | 1918 | ```tsx 1919 | // ✅ Good 1920 | test('User can add items to cart', async () => { 1921 | render() 1922 | await userEvent.click(screen.getByRole('button', { name: /add to cart/i })) 1923 | await expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument() 1924 | }) 1925 | 1926 | // ❌ Avoid 1927 | test('Cart state updates when addToCart is called', () => { 1928 | const { container } = render() 1929 | const addButton = container.querySelector('[data-testid="add-button"]') 1930 | fireEvent.click(addButton) 1931 | expect( 1932 | container.querySelector('[data-testid="cart-count"]'), 1933 | ).toHaveTextContent('1') 1934 | }) 1935 | ``` 1936 | 1937 | ### Use userEvent Over fireEvent 1938 | 1939 | Use userEvent over fireEvent for more realistic user interactions: 1940 | 1941 | ```tsx 1942 | // ✅ Good 1943 | await userEvent.type(screen.getByRole('textbox'), 'Hello') 1944 | 1945 | // ❌ Avoid 1946 | fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello' } }) 1947 | ``` 1948 | 1949 | ## Misc 1950 | 1951 | ### File naming 1952 | 1953 | Use kebab-case for file names. 1954 | 1955 | ```tsx 1956 | // ✅ Good 1957 | import { HighlightButton } from './highlight-button' 1958 | 1959 | // ❌ Avoid 1960 | import { HighlightButton } from './HighlightButton' 1961 | ``` 1962 | 1963 | It makes things work consistently on Windows and Unix-based systems. 1964 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export { default } from './eslint.js' 2 | -------------------------------------------------------------------------------- /eslint.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | 3 | const ERROR = 'error' 4 | const WARN = 'warn' 5 | 6 | const has = (pkg) => { 7 | try { 8 | import.meta.resolve(pkg, import.meta.url) 9 | return true 10 | } catch { 11 | return false 12 | } 13 | } 14 | 15 | const hasTypeScript = has('typescript') 16 | const hasReact = has('react') 17 | const hasTestingLibrary = has('@testing-library/dom') 18 | const hasJestDom = has('@testing-library/jest-dom') 19 | const hasVitest = has('vitest') 20 | const hasPlaywright = has('playwright') 21 | 22 | const vitestFiles = ['**/__tests__/**/*', '**/*.test.*', '**/*.spec.*'] 23 | const testFiles = ['**/tests/**', '**/#tests/**', ...vitestFiles] 24 | const playwrightFiles = ['**/tests/e2e/**'] 25 | 26 | export const config = [ 27 | { 28 | ignores: [ 29 | '**/.cache/**', 30 | '**/node_modules/**', 31 | '**/build/**', 32 | '**/public/**', 33 | '**/*.json', 34 | '**/playwright-report/**', 35 | '**/server-build/**', 36 | '**/dist/**', 37 | '**/coverage/**', 38 | '**/*.tsbuildinfo', 39 | ], 40 | }, 41 | 42 | // all files 43 | { 44 | plugins: { 45 | import: (await import('eslint-plugin-import-x')).default, 46 | }, 47 | languageOptions: { 48 | globals: { 49 | ...globals.browser, 50 | ...globals.node, 51 | }, 52 | }, 53 | rules: { 54 | 'no-unexpected-multiline': ERROR, 55 | 'no-warning-comments': [ 56 | ERROR, 57 | { terms: ['FIXME'], location: 'anywhere' }, 58 | ], 59 | 'import/no-duplicates': [WARN, { 'prefer-inline': true }], 60 | 'import/order': [ 61 | WARN, 62 | { 63 | alphabetize: { order: 'asc', caseInsensitive: true }, 64 | pathGroups: [{ pattern: '#*/**', group: 'internal' }], 65 | groups: [ 66 | 'builtin', 67 | 'external', 68 | 'internal', 69 | 'parent', 70 | 'sibling', 71 | 'index', 72 | ], 73 | }, 74 | ], 75 | }, 76 | }, 77 | 78 | // JSX/TSX files 79 | hasReact 80 | ? { 81 | files: ['**/*.tsx', '**/*.jsx'], 82 | plugins: { 83 | react: (await import('eslint-plugin-react')).default, 84 | }, 85 | languageOptions: { 86 | parser: hasTypeScript 87 | ? (await import('typescript-eslint')).parser 88 | : undefined, 89 | parserOptions: { 90 | jsx: true, 91 | }, 92 | }, 93 | rules: { 94 | 'react/jsx-key': WARN, 95 | }, 96 | } 97 | : null, 98 | 99 | // react-hook rules are applicable in ts/js/tsx/jsx, but only with React as a 100 | // dep 101 | hasReact 102 | ? { 103 | files: ['**/*.ts?(x)', '**/*.js?(x)'], 104 | plugins: { 105 | 'react-hooks': (await import('eslint-plugin-react-hooks')).default, 106 | }, 107 | rules: { 108 | 'react-hooks/rules-of-hooks': ERROR, 109 | 'react-hooks/exhaustive-deps': WARN, 110 | }, 111 | } 112 | : null, 113 | 114 | // JS and JSX files 115 | { 116 | files: ['**/*.js?(x)'], 117 | rules: { 118 | 'no-undef': ERROR, 119 | 120 | // most of these rules are useful for JS but not TS because TS handles these better 121 | // if it weren't for https://github.com/import-js/eslint-plugin-import/issues/2132 122 | // we could enable this :( 123 | // 'import/no-unresolved': ERROR, 124 | 'no-unused-vars': [ 125 | WARN, 126 | { 127 | args: 'after-used', 128 | argsIgnorePattern: '^_', 129 | ignoreRestSiblings: true, 130 | varsIgnorePattern: '^ignored', 131 | }, 132 | ], 133 | }, 134 | }, 135 | 136 | // TS and TSX files 137 | hasTypeScript 138 | ? { 139 | files: ['**/*.ts?(x)'], 140 | languageOptions: { 141 | parser: (await import('typescript-eslint')).parser, 142 | parserOptions: { 143 | projectService: true, 144 | }, 145 | }, 146 | plugins: { 147 | '@typescript-eslint': (await import('typescript-eslint')).plugin, 148 | }, 149 | rules: { 150 | '@typescript-eslint/no-unused-vars': [ 151 | WARN, 152 | { 153 | args: 'after-used', 154 | argsIgnorePattern: '^_', 155 | ignoreRestSiblings: true, 156 | varsIgnorePattern: '^ignored', 157 | }, 158 | ], 159 | 'import/consistent-type-specifier-style': [WARN, 'prefer-inline'], 160 | '@typescript-eslint/consistent-type-imports': [ 161 | WARN, 162 | { 163 | prefer: 'type-imports', 164 | disallowTypeAnnotations: true, 165 | fixStyle: 'inline-type-imports', 166 | }, 167 | ], 168 | 169 | '@typescript-eslint/no-misused-promises': [ 170 | 'error', 171 | { checksVoidReturn: false }, 172 | ], 173 | 174 | '@typescript-eslint/no-floating-promises': 'error', 175 | 176 | // here are rules we've decided to not enable. Commented out rather 177 | // than setting them to disabled to avoid them being referenced at all 178 | // when config resolution happens. 179 | 180 | // @typescript-eslint/require-await - sometimes you really do want 181 | // async without await to make a function async. TypeScript will ensure 182 | // it's treated as an async function by consumers and that's enough for me. 183 | 184 | // @typescript-eslint/prefer-promise-reject-errors - sometimes you 185 | // aren't the one creating the error, and you just want to propagate an 186 | // error object with an unknown type. 187 | 188 | // @typescript-eslint/only-throw-error - same reason as above. 189 | // However, this rule supports options to allow you to throw `any` and 190 | // `unknown`. Unfortunately, in Remix you can throw Response objects, 191 | // and we don't want to enable this rule for those cases. 192 | 193 | // @typescript-eslint/no-unsafe-declaration-merging - this is a rare 194 | // enough problem (especially if you focus on types over interfaces) 195 | // that it's not worth enabling. 196 | 197 | // @typescript-eslint/no-unsafe-enum-comparison - enums are not 198 | // recommended or used in epic projects, so it's not worth enabling. 199 | 200 | // @typescript-eslint/no-unsafe-unary-minus - this is a rare enough 201 | // problem that it's not worth enabling. 202 | 203 | // @typescript-eslint/no-base-to-string - this doesn't handle when 204 | // your object actually does implement toString unless you do so with 205 | // a class which is not 100% of the time. For example, the timings 206 | // object in the epic stack uses defineProperty to implement toString. 207 | // It's not high enough risk/impact to enable. 208 | 209 | // @typescript-eslint/no-non-null-assertion - normally you should not 210 | // use ! to tell TS to ignore the null case, but you're a responsible 211 | // adult and if you're going to do that, the linter shouldn't yell at 212 | // you about it. 213 | 214 | // @typescript-eslint/restrict-template-expressions - toString is a 215 | // feature of many built-in objects and custom ones. It's not worth 216 | // enabling. 217 | 218 | // @typescript-eslint/no-confusing-void-expression - what's confusing 219 | // to one person isn't necessarily confusing to others. Arrow 220 | // functions that call something that returns void is not confusing 221 | // and the types will make sure you don't mess something up. 222 | 223 | // these each protect you from `any` and while it's best to avoid 224 | // using `any`, it's not worth having a lint rule yell at you when you 225 | // do: 226 | // - @typescript-eslint/no-unsafe-argument 227 | // - @typescript-eslint/no-unsafe-call 228 | // - @typescript-eslint/no-unsafe-member-access 229 | // - @typescript-eslint/no-unsafe-return 230 | // - @typescript-eslint/no-unsafe-assignment 231 | }, 232 | } 233 | : null, 234 | 235 | // This assumes test files are those which are in the test directory or have 236 | // *.test.* or *.spec.* in the filename. If a file doesn't match this assumption, 237 | // then it will not be allowed to import test files. 238 | { 239 | files: ['**/*.ts?(x)', '**/*.js?(x)'], 240 | ignores: testFiles, 241 | rules: { 242 | 'no-restricted-imports': [ 243 | ERROR, 244 | { 245 | patterns: [ 246 | { 247 | group: testFiles, 248 | message: 'Do not import test files in source files', 249 | }, 250 | ], 251 | }, 252 | ], 253 | }, 254 | }, 255 | 256 | hasTestingLibrary 257 | ? { 258 | files: testFiles, 259 | ignores: [...playwrightFiles], 260 | plugins: { 261 | 'testing-library': (await import('eslint-plugin-testing-library')) 262 | .default, 263 | }, 264 | rules: { 265 | 'testing-library/no-unnecessary-act': [ERROR, { isStrict: false }], 266 | 'testing-library/no-wait-for-side-effects': ERROR, 267 | 'testing-library/prefer-find-by': ERROR, 268 | }, 269 | } 270 | : null, 271 | 272 | hasJestDom 273 | ? { 274 | files: testFiles, 275 | ignores: [...playwrightFiles], 276 | plugins: { 277 | 'jest-dom': (await import('eslint-plugin-jest-dom')).default, 278 | }, 279 | rules: { 280 | 'jest-dom/prefer-checked': ERROR, 281 | 'jest-dom/prefer-enabled-disabled': ERROR, 282 | 'jest-dom/prefer-focus': ERROR, 283 | 'jest-dom/prefer-required': ERROR, 284 | }, 285 | } 286 | : null, 287 | 288 | hasVitest 289 | ? { 290 | files: testFiles, 291 | ignores: [...playwrightFiles], 292 | plugins: { 293 | vitest: (await import('@vitest/eslint-plugin')).default, 294 | }, 295 | rules: { 296 | // you don't want the editor to autofix this, but we do want to be 297 | // made aware of it 298 | 'vitest/no-focused-tests': [WARN, { fixable: false }], 299 | 'vitest/no-import-node-test': ERROR, 300 | 'vitest/prefer-comparison-matcher': ERROR, 301 | 'vitest/prefer-equality-matcher': ERROR, 302 | 'vitest/prefer-to-be': ERROR, 303 | 'vitest/prefer-to-contain': ERROR, 304 | 'vitest/prefer-to-have-length': ERROR, 305 | 'vitest/valid-expect-in-promise': ERROR, 306 | 'vitest/valid-expect': ERROR, 307 | 308 | // vitest/expect-expect - we don't enable this because it's fine to 309 | // rely on testing-library to throw errors if elements aren't found. 310 | }, 311 | } 312 | : null, 313 | 314 | hasPlaywright 315 | ? { 316 | files: [...playwrightFiles], 317 | plugins: { 318 | playwright: (await import('eslint-plugin-playwright')).default, 319 | }, 320 | rules: { 321 | 'playwright/max-nested-describe': ERROR, 322 | 'playwright/missing-playwright-await': ERROR, 323 | 'playwright/no-focused-test': WARN, 324 | 'playwright/no-page-pause': ERROR, 325 | 'playwright/no-raw-locators': [WARN, { allowed: ['iframe'] }], 326 | 'playwright/no-slowed-test': ERROR, 327 | 'playwright/no-standalone-expect': ERROR, 328 | 'playwright/no-unsafe-references': ERROR, 329 | 'playwright/prefer-comparison-matcher': ERROR, 330 | 'playwright/prefer-equality-matcher': ERROR, 331 | 'playwright/prefer-native-locators': ERROR, 332 | 'playwright/prefer-to-be': ERROR, 333 | 'playwright/prefer-to-contain': ERROR, 334 | 'playwright/prefer-to-have-count': ERROR, 335 | 'playwright/prefer-to-have-length': ERROR, 336 | 'playwright/prefer-web-first-assertions': ERROR, 337 | 'playwright/valid-expect-in-promise': ERROR, 338 | 'playwright/valid-expect': ERROR, 339 | 340 | // playwright/expect-expect - we don't enable this because it's fine to 341 | // rely on thrown errors if elements aren't found. 342 | }, 343 | } 344 | : null, 345 | ].filter(Boolean) 346 | 347 | // this is for backward compatibility 348 | export default config 349 | -------------------------------------------------------------------------------- /fixture/README.md: -------------------------------------------------------------------------------- 1 | # Fixture 2 | 3 | This is just a place to dump code and play around to check that the config works 4 | as expected. 5 | -------------------------------------------------------------------------------- /fixture/app/components/__tests__/accordion.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | export const MockAccordion = () =>
Accordion
4 | 5 | test.skip('hello', () => { 6 | expect(true).toBe(true) 7 | }) 8 | -------------------------------------------------------------------------------- /fixture/app/components/accordion.spec.tsx: -------------------------------------------------------------------------------- 1 | export const MockAccordion = () =>
Accordion
2 | -------------------------------------------------------------------------------- /fixture/app/components/accordion.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import { MockAccordion } from './__tests__/accordion.tsx' 3 | // eslint-disable-next-line 4 | import { MockAccordion as SpecMockAccordion } from './accordion.spec.tsx' 5 | 6 | console.log(MockAccordion) 7 | console.log(SpecMockAccordion) 8 | -------------------------------------------------------------------------------- /fixture/app/components/swizzle.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export function Counter() { 4 | const [count, setCount] = useState(0) 5 | const increment = () => setCount((c) => c + 1) 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /fixture/index.js: -------------------------------------------------------------------------------- 1 | import console from 'node:console' 2 | 3 | console.log('hi') 4 | -------------------------------------------------------------------------------- /fixture/react.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export function Counter() { 4 | const [count, setCount] = useState(0) 5 | const increment = () => setCount((c) => c + 1) 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /fixture/tests/smoke.e2e.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test' 2 | 3 | test('hello', async ({ page }) => { 4 | await page.goto('http://localhost:5173') 5 | }) 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | throw new Error('@epic-web/config does not have a default export module') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/package", 3 | "name": "@epic-web/config", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "version": "0.0.0-semantically-released", 8 | "description": "Reasonable ESLint configs for epic web devs", 9 | "main": "index.js", 10 | "type": "module", 11 | "imports": { 12 | "#/*": "./*" 13 | }, 14 | "exports": { 15 | ".": "./index.js", 16 | "./prettier": "./prettier.js", 17 | "./typescript": "./typescript.json", 18 | "./reset.d.ts": "./reset.d.ts", 19 | "./eslint": "./eslint.js" 20 | }, 21 | "prettier": "./prettier.js", 22 | "scripts": { 23 | "format": "prettier . --write", 24 | "lint": "eslint .", 25 | "typecheck": "tsc", 26 | "validate": "run-p -l format lint typecheck" 27 | }, 28 | "keywords": [ 29 | "config", 30 | "eslint", 31 | "prettier", 32 | "typescript", 33 | "epic" 34 | ], 35 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/epicweb-dev/config" 39 | }, 40 | "homepage": "https://github.com/epicweb-dev/config", 41 | "license": "MIT", 42 | "dependencies": { 43 | "@total-typescript/ts-reset": "^0.6.1", 44 | "@vitest/eslint-plugin": "^1.1.43", 45 | "eslint-plugin-import-x": "^4.11.0", 46 | "eslint-plugin-jest-dom": "^5.5.0", 47 | "eslint-plugin-playwright": "^2.2.0", 48 | "eslint-plugin-react": "^7.37.5", 49 | "eslint-plugin-react-hooks": "^5.2.0", 50 | "eslint-plugin-testing-library": "^7.1.1", 51 | "globals": "^16.0.0", 52 | "prettier-plugin-tailwindcss": "^0.6.11", 53 | "tslib": "^2.8.1", 54 | "typescript-eslint": "^8.31.0" 55 | }, 56 | "devDependencies": { 57 | "@playwright/test": "^1.52.0", 58 | "@types/react": "^19.1.2", 59 | "eslint": "^9.25.1", 60 | "npm-run-all": "^4.1.5", 61 | "prettier": "^3.5.3", 62 | "react": "^19.1.0", 63 | "typescript": "^5.8.3", 64 | "vitest": "^3.1.2" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /prettier.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Options} */ 2 | export const config = { 3 | arrowParens: 'always', 4 | bracketSameLine: false, 5 | bracketSpacing: true, 6 | embeddedLanguageFormatting: 'auto', 7 | endOfLine: 'lf', 8 | htmlWhitespaceSensitivity: 'css', 9 | insertPragma: false, 10 | jsxSingleQuote: false, 11 | printWidth: 80, 12 | proseWrap: 'always', 13 | quoteProps: 'as-needed', 14 | requirePragma: false, 15 | semi: false, 16 | singleAttributePerLine: false, 17 | singleQuote: true, 18 | tabWidth: 2, 19 | trailingComma: 'all', 20 | useTabs: true, 21 | overrides: [ 22 | // formatting the package.json with anything other than spaces will cause 23 | // issues when running install... 24 | { 25 | files: ['**/package.json'], 26 | options: { 27 | useTabs: false, 28 | }, 29 | }, 30 | { 31 | files: ['**/*.mdx'], 32 | options: { 33 | // This stinks, if you don't do this, then an inline component on the 34 | // end of the line will end up wrapping, then the next save Prettier 35 | // will add an extra line break. Super annoying and probably a bug in 36 | // Prettier, but until it's fixed, this is the best we can do. 37 | proseWrap: 'preserve', 38 | htmlWhitespaceSensitivity: 'ignore', 39 | }, 40 | }, 41 | ], 42 | plugins: ['prettier-plugin-tailwindcss'], 43 | tailwindAttributes: ['class', 'className', 'ngClass', '.*[cC]lassName'], 44 | tailwindFunctions: ['clsx', 'cn', 'cva'], 45 | } 46 | 47 | // this is for backward compatibility 48 | export default config 49 | -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@total-typescript/ts-reset/dom' 2 | 3 | import 'react' 4 | 5 | declare module 'react' { 6 | // support css variables 7 | interface CSSProperties { 8 | [key: `--${string}`]: string | number 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./typescript.json", 3 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"] 4 | } 5 | -------------------------------------------------------------------------------- /typescript.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "isolatedModules": true, 5 | "jsx": "react-jsx", 6 | "module": "preserve", 7 | "target": "ES2022", 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "allowImportingTsExtensions": true, 11 | "noUncheckedIndexedAccess": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------