TypeScript Patterns We Actually Use (And the Strictness That Earns Its Friction)

by Maven Team, Software Development

TypeScript has a strange reputation. Half the internet insists you should turn every strictness flag to maximum and never write any again; the other half quietly litters codebases with as any and // @ts-ignore because the first group's advice made day-to-day work miserable. Both are reacting to the same real thing: TypeScript gives you an enormous amount of expressive power, and most of the pain comes from using the wrong amount of it in the wrong place.

We build React and Next.js applications for a living, and over enough projects you develop opinions about which parts of the language pull their weight and which are cleverness for its own sake. This is not a beginner's tour of generics. It is an honest account of the patterns we reach for constantly, the compiler settings that are worth the friction they cause, and the ones we have learned to leave off, because a type system people fight is a type system people route around, and a routed-around type system is worse than none.

The point of types is to make bad states unrepresentable

If there is one idea underneath everything else here, it is this: the job of a type is not to describe the data, it is to forbid the data you never want to see. A type that permits states your code cannot actually handle is a type that will lie to you. The principle is not ours; it is Yaron Minsky's "make illegal states unrepresentable" from the OCaml world, which Richard Feldman later brought to front-end developers in his talk "Making Impossible States Impossible". It is worth the detour because it is the highest-leverage habit in this article.

The everyday offender is the "bag of optional booleans" that React components accumulate:

type State = {
  isLoading: boolean
  data?: User
  error?: string
}

This looks reasonable and it is quietly broken. It permits isLoading: true with an error and data present at the same time, a state that makes no sense but that the compiler will happily wave through, leaving you to handle it defensively at every call site. The fix is a discriminated union that only allows the states that really exist:

type State =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: User }

Now data cannot be accessed unless status is 'success', the compiler enforces it, and every impossible combination is simply unrepresentable. This single pattern, the discriminated union with a status or kind tag, is the one we use more than any other, in reducers, in API responses, in form state, in anything with modes. When you find yourself reaching for a second optional field, stop and ask whether you are really modelling a set of distinct states, because you usually are.

Infer, don't annotate

A lot of TypeScript friction is self-inflicted, from writing down types the compiler already knows. The instinct from other languages is to annotate everything; in TypeScript that instinct produces noise, duplication, and types that drift out of sync with the values they describe.

Let inference do the work, and derive types from the source of truth rather than declaring them alongside it. If you have a constant, typeof it. If you have a schema, infer from the schema. If you have a function, let its return type be inferred and read it back out when you need it:

const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = (typeof ROLES)[number] // 'admin' | 'editor' | 'viewer'

type ApiResponse = Awaited<ReturnType<typeof fetchUser>>

The as const there is one of the highest-value few characters in the language: it turns a mutable string[] into a readonly tuple of literals, which is what lets you derive a union from an array you can also iterate at runtime. One source of truth, used both as data and as a type. Change the array and the type follows automatically, no second place to update, no chance of the two disagreeing.

Validate at the boundary, trust the inside

This is the pattern that changes how a codebase feels more than any other, and it is the one people most often get backwards. TypeScript's types are erased at runtime. They describe what should be true; they do nothing to make it true. The moment data enters your application from outside (an API response, a form submission, localStorage, a URL parameter, an environment variable), a type annotation on it is a hope, not a guarantee. const user = await res.json() as User is one of the most common lies in a TypeScript codebase: you have told the compiler it is a User, and if the API returns something else, you will find out via an unhelpful crash three components deep.

The discipline is to validate once, at the boundary, with a schema, and derive the static type from that schema so the two can never disagree:

import { z } from 'zod'

const User = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
})

type User = z.infer<typeof User>

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  return User.parse(await res.json()) // throws loudly if the shape is wrong
}

Now the boundary is the only place that distrusts the data. Everything inside getUser and everywhere it flows can treat a User as genuinely being a User, because it was checked at runtime, not merely asserted. The static type and the runtime check come from the same declaration, so adding a field is one edit, not two. This is where we spend our validation effort, the edges, and it is why the interior of a well-built app can lean on the compiler with a clear conscience.

The strictness that earns its friction

strict: true is not one setting: it is a bundle, and it is the right default. But it is worth understanding which of its members do the heavy lifting, because when someone proposes turning strictness up beyond it, you want to judge each flag on whether it catches real bugs or just generates ceremony.

These earn their place, unarguably:

  • strictNullChecks. The single most valuable thing the compiler does. It forces you to confront the fact that a value might be null or undefined at the point you use it, which, in our experience, is where most runtime "cannot read properties of undefined" crashes come from. If you adopt nothing else, adopt this. On a legacy codebase it is also the most expensive to turn on, but it is expensive precisely because it is finding real holes.
  • noImplicitAny. Stops values silently becoming any and taking whole call chains off the map with them. An explicit any you chose is a decision; an implicit one you did not notice is a leak.
  • noUncheckedIndexedAccess. Not in strict by default, and we turn it on anyway. It makes arr[0] and record[key] return T | undefined instead of T, because indexing can miss. It is mildly annoying and it is correct: array and object access is exactly the kind of "I assumed it was there" that blows up in production. This is the one "extra" flag we recommend almost universally.

And the friction that we think mostly is not worth it:

  • exactOptionalPropertyTypes. Distinguishes "property absent" from "property present and undefined." Technically more correct; in practice it generates a stream of complaints about code that was completely fine, and the bugs it catches are vanishingly rare in a React codebase. We usually leave it off.
  • Reflexive any-banning lint rules turned to error. Banning any outright sounds disciplined and mostly teaches people to write as unknown as Thing, which is strictly worse because it is invisible. Better to allow any and unknown where genuinely needed, require a comment, and review them. A small number of honest escape hatches beats a large number of hidden ones.

The through-line: a flag is worth its friction when the friction corresponds to a real bug it is stopping you from writing. When the friction is just the compiler being pedantic about something that was never going to break, it teaches your team to reach for the escape hatch, and every time they do, they get a little more comfortable reaching for it next time, on the cases that would have broken.

The compiler underneath just got rewritten

Everything above is about how you write types. Mid-2026 brought a change to what happens after you write them, and it is the biggest thing to land in the toolchain in years. TypeScript 7.0, which reached general availability on 8 July 2026 and is typescript@latest (7.0.2) as we write this, is a complete reimplementation of the compiler and language service in Go. Microsoft ran it under the codename Project Corsa; the practical headline, in Microsoft's own numbers, is that full builds run 8 to 12x faster. That is the kind of jump that turns a multi-minute tsc in CI into a few seconds, and stops the editor lagging on a large monorepo.

The thing to grasp before you get excited is what did not change. TS 7 is a faithful, file-by-file port of the existing compiler, not a redesign. Its type-checking behaviour is deliberately identical to TypeScript 6.0's: code that compiled cleanly before compiles identically now, with the same errors in the same places. So there are no new type-system features here; the speed is the feature. Every pattern in this article behaves exactly the same on 7.0 as on 6.x. What you gain is a shorter feedback loop (the tsc in your pre-commit hook, the red squiggle in the editor, the type-check step in CI), and a shorter feedback loop is quietly one of the most valuable things you can buy a team.

So is it worth adopting yet? For a plain React or Next.js app whose CI mostly runs tsc --noEmit, the risk is genuinely low: the checking is identical and the executable is still called tsc. Two caveats set the timing, both called out in Microsoft's release notes. First, 7.0 ships without a stable programmatic compiler API (Microsoft expects that in 7.1), so anything that reaches into the compiler still lags. Type-aware typescript-eslint rules, and the embedded-TypeScript tooling for meta-frameworks like Vue, Svelte, Astro, and MDX, are explicitly named as not yet able to lean on 7.0. If your lint or your framework depends on that API, run 7 for builds only, or wait. Second, strict mode and the 6.0 deprecations are now hard defaults, a non-event if you already run strict, as this article argues you should, and a small migration if you do not.

Our current stance: run TS 7 for build and type-check speed wherever the surrounding toolchain supports it, keep 6.0 available side-by-side (it installs as tsc6 via the compatibility package) as a fallback, and hold off on switching the parts of the pipeline that depend on the compiler API until 7.1 settles. It is the rare upgrade that makes everything faster and asks you to change none of your code, provided the tools around it have caught up.

Utility types, sparingly and on purpose

TypeScript ships a set of built-in transformations (Pick, Omit, Partial, Required, Record) that are genuinely useful for keeping types in sync without duplication. Deriving a form's draft type as Partial<User>, or an API's public shape as Omit<User, 'passwordHash'>, means the derived type updates automatically when the base one changes. That is the good case: one source of truth, mechanically transformed.

The bad case is the type-level program. TypeScript's type system is Turing-complete, and it is entirely possible to build recursive conditional types with infer that manipulate string literals and rebuild object shapes at compile time. Occasionally, in a library that types a query builder or a router, this is warranted. In application code it almost never is. A type nobody on the team can read, that produces error messages nobody can decode, and that adds seconds to every build, is not clever. It is a liability with good PR. Our rule of thumb: if a colleague cannot understand the type in the time it takes to read it, it belongs in a library, behind a simple public signature, or not at all.

The same restraint applies to generics. Generics are for genuinely reusable code where the type varies, a useFetch<T> hook, a typed event emitter. They are not for a component that will only ever render a User. A concrete type is easier to read, easier to error-message, and easier for the next person to change. Reach for a generic when you have actually seen the second use case, not in anticipation of one.

Where we land

TypeScript rewards judgement, not maximalism. The patterns that pay off every single day are unglamorous: model your states as discriminated unions so impossible states cannot exist; infer types from a single source of truth instead of declaring them twice; validate untrusted data at the boundary with a schema and trust it thereafter; run strict plus noUncheckedIndexedAccess and think twice before adding more; and use utility types and generics for real reuse, not as a puzzle.

The failure mode at both extremes is the same. Too little typing and the compiler cannot help you. Too much, or too clever, and people quietly opt out with as any, at which point you are paying the full cost of the type system and getting a fraction of the safety. The goal is a codebase where the types catch the mistakes that matter and stay out of the way of the work that does not, so that reaching for the escape hatch feels like a failure rather than a relief. Get that balance right and TypeScript stops being a tax you pay and becomes the thing that lets a team change a large application quickly without being afraid of it, and with the 7.0 compiler underneath, it now does that at a speed that no longer punishes you for the check.

Sources

  • Microsoft TypeScript team, "Announcing TypeScript 7.0" (8 July 2026). GA date, the 8 to 12x speedup, type-checking parity with 6.0, the absence of a stable API until 7.1, the tsc6 compatibility package, and the Vue/Svelte/Astro/MDX and typescript-eslint caveats.
  • Anders Hejlsberg / Microsoft TypeScript team, "A 10x Faster TypeScript" (March 2025). The original native-port announcement and the Corsa codename.
  • npm registry: typescript latest = 7.0.2 at the time of writing.
  • Yaron Minsky, "Effective ML Revisited" (Jane Street) and Richard Feldman, "Making Impossible States Impossible" (elm-conf 2016). Origin of "make illegal states unrepresentable".

More articles

How Do You Actually Test an AI Feature? Evals for Teams Shipping LLM Products

Unit tests assume the same input gives the same output. LLMs break that assumption on purpose. Here is how teams actually test AI features, building an eval set, LLM-as-judge, regression testing in CI, and what "good enough to ship" really means.

Read more

What Actually Gets You Cited by AI Answer Engines in 2026

Answer engine optimisation is drowning in hype and quick-fix files. Here is what the data actually shows about getting cited by ChatGPT, Perplexity, and Google AI Overviews, and what is just theatre.

Read more

Bring us the messy, valuable software problem.

We will help you decide what to build, what to modernise, and where the highest-return work actually is.

Our offices

  • London
    71-75, Shelton Street,
    Covent Garden, London