DEV Community

Cover image for TypeScript or Tears
nicolas.vbgh
nicolas.vbgh

Posted on

TypeScript or Tears

Frontend Quality Gates

See also: Backend Quality Gates

Backend linters catch async footguns. Type checkers prevent runtime explosions. Now the frontend's turn.

JavaScript fails silently. That's the whole problem in one sentence.

You call a function with the wrong arguments. It runs. You access a property that doesn't exist. It runs. You forget to handle null. It runs. Everything runs. Nothing works. Users complain. You have no idea why.

This is fine. Everything is fine.

Just kidding. This is chaos. And like the backend, none of this is even testing yet. We're just checking that the code isn't obviously broken before we bother running actual tests.

The bar is still on the floor. Let's at least step over it.


TypeScript: Because JavaScript Trusts You Too Much

JavaScript has no types. This was a design decision. It was wrong.

function processUser(user) {
  return user.name.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

What is user? An object? A string? A promise? JavaScript doesn't know. JavaScript doesn't care. JavaScript believes anything is possible.

JavaScript is an optimist. You shouldn't be.

TypeScript strict mode forces you to say what things are:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the compiler yells at you:

// TypeScript rejects this
function processUser(user) {  // Error: implicit 'any'
  return user.name.toUpperCase();
}

// TypeScript accepts this
function processUser(user: User): string {
  return user.name.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

Is it annoying? Yes. Does it catch bugs before users do? Also yes. That's the trade.

I use strict: true and noUncheckedIndexedAccess. The second one is particularly annoying. It assumes array access might return undefined. Because it might. And you should handle that.


ESLint: The Nitpicker You Need

TypeScript catches type errors. ESLint catches everything else.

Unused variables. Inconsistent formatting. Dangerous patterns. That console.log you left in production code. ESLint notices. ESLint judges.

I use the Airbnb style guide. It's opinionated, strict, and battle-tested. Thousands of engineers have argued about these rules so I don't have to.

// eslint.config.js
import { configs, extensions, plugins } from 'eslint-config-airbnb-extended';

export default [
  plugins.stylistic,
  plugins.importX,
  plugins.react,
  plugins.reactA11y,
  plugins.reactHooks,
  plugins.typescriptEslint,

  ...extensions.base.typescript,
  ...extensions.react.typescript,

  ...configs.react.recommended,
  ...configs.react.typescript,
];
Enter fullscreen mode Exit fullscreen mode

One import. Hundreds of rules. TypeScript support, React hooks, accessibility, import ordering. All preconfigured.

no-explicit-any is the important one. It closes the escape hatch. You can't just type any and pretend you have type safety. You either type things properly or the build fails.

Some people find this restrictive. Those people debug production issues at 2 AM. I watch Netflix at 2 AM. Different choices.


Storybook: Because Components Lie

Here's a fun game: refactor a component, run the app, click around, ship it. Three months later, discover you broke the loading state on a page nobody visits often.

Components have many states. Happy path, error state, loading state, empty state, edge cases. You can't manually test all of them every time. You won't. I won't. Nobody will.

Storybook documents every state:

// Button.stories.tsx
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Click me',
  },
};

export const Loading: Story = {
  args: {
    isLoading: true,
    children: 'Loading...',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: 'Nope',
  },
};
Enter fullscreen mode Exit fullscreen mode

Run build-storybook in CI. If any component can't render in any state, the build fails. You broke something. Fix it before it ships.

Bonus: AI can read these stories. It understands what states exist. It generates code that handles them. Documentation that actually gets used.


The Gate

One job per check. When something fails, you know exactly what.

.frontend-quality:
  stage: quality
  image: node:lts-slim
  cache:
    key: npm-frontend-${CI_COMMIT_REF_SLUG}
    paths:
      - frontend/node_modules
      - ~/.npm
  before_script:
    - cd frontend
    - npm ci --prefer-offline
  allow_failure: false
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

eslint:
  extends: .frontend-quality
  script:
    - npm run lint

typecheck:
  extends: .frontend-quality
  script:
    - npm run typecheck

storybook:build:
  extends: .frontend-quality
  script:
    - npm run build-storybook --quiet
  artifacts:
    paths:
      - frontend/storybook-static
    expire_in: 1 week
    when: always
Enter fullscreen mode Exit fullscreen mode

Three jobs. Same stage. Run in parallel.

The hidden job.frontend-quality starts with a dot. GitLab won't run it directly. It's a template.

npm ci — Not npm install. ci is faster, stricter, and uses the lockfile exactly. No surprises.

--prefer-offline — Use cached packages when possible. Network is slow. Cache is fast.

Parallel execution — All three jobs run at the same time. ESLint fails? You see it immediately. TypeScript fails too? You see both. Fix them together.

Storybook artifacts — Keep the built Storybook around. when: always saves it even if the build fails. Useful for debugging why that one component broke.

When one job fails, you see exactly which one in the pipeline view. No scrolling through logs. No guessing.

  • eslint failed → style issues or dangerous patterns
  • typecheck failed → type errors
  • storybook:build failed → component can't render

None of this verifies that the code does what it should. That's what tests are for. This just verifies it's not obviously broken.

The bar is low. But you'd be surprised how many projects can't clear it.

Here's the full picture:

stages:
  - quality

.frontend-quality:
  stage: quality
  image: node:lts-slim
  cache:
    key: npm-frontend-${CI_COMMIT_REF_SLUG}
    paths:
      - frontend/node_modules
      - ~/.npm
  before_script:
    - cd frontend
    - npm ci --prefer-offline
  allow_failure: false
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

eslint:
  extends: .frontend-quality
  script:
    - npm run lint

typecheck:
  extends: .frontend-quality
  script:
    - npm run typecheck

storybook:build:
  extends: .frontend-quality
  script:
    - npm run build-storybook --quiet
  artifacts:
    paths:
      - frontend/storybook-static
    expire_in: 1 week
    when: always
Enter fullscreen mode Exit fullscreen mode

Copy, paste, adapt. It works.


The Point

Frontend code fails in creative ways. Silent failures. Runtime errors. "It works on my machine" but not in Safari. Components that render fine until you pass them unexpected props.

I can't catch all of this manually. My attention span isn't that good. Nobody's is.

So I automate the obvious stuff:

  • Types must be explicit
  • Code must follow consistent patterns
  • Components must render without crashing

The machines catch what I miss. The pipeline blocks what I'd regret.

Same deal as the backend. Write the rules once. Enforce them forever.


Next up: [Security] -coming soon- — Dependencies are other people's code. And other people make mistakes.

Top comments (0)