TypeScript or Tears

Published: (February 3, 2026 at 03:00 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

See also: Backend Quality Gates

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

The Problem

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.

Like the backend, none of this is even testing yet. We’re only 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.

Why JavaScript Needs Types

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

function processUser(user) {
  return user.name.toUpperCase();
}

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.

Enter TypeScript (strict mode)

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

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();
}

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

I use strict: true and noUncheckedIndexedAccess. The latter is particularly annoying because it assumes array access might return undefined—and it can, so you should handle it.

Linting with ESLint

TypeScript catches type errors. ESLint catches everything else:

  • Unused variables
  • Inconsistent formatting
  • Dangerous patterns
  • Forgotten console.log in production

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,
];

One import. Hundreds of rules. TypeScript support, React hooks, accessibility, import ordering—all pre‑configured.

no-explicit-any is the important one. It closes the escape hatch: you can’t just type any and pretend you have type safety. 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.

Documenting Component States with Storybook

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',
  },
};

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, understand what states exist, and generate code that handles them. Documentation that actually gets used.

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

Three jobs. Same stage. Run in parallel.

  • The hidden job (.frontend-quality) starts with a dot, so GitLab treats it as a template (it won’t run directly).
  • 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? You see both. Fix them together.
  • Storybook build fails? You see it too.

Storybook artifacts keep the built Storybook around. when: always saves it even if the build fails—useful for debugging why a component broke.

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

JobFailure Means
eslintStyle issues or dangerous patterns
typecheckType errors
storybook:buildComponent can’t render in a story

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.

Full CI Configuration

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

Copy, paste, adapt. It works.

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 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.

Back to Blog

Related posts

Read more »

Deno Sandbox

Article URL: https://deno.com/blog/introducing-deno-sandbox Comments URL: https://news.ycombinator.com/item?id=46874097 Points: 57 Comments: 9...

UI Modifications Summary

Overview Implemented multiple UI improvements to enhance user experience and fix broken features. Changes Implemented 1. Background Settings – Removed “Add Fir...