May 31, 2026

· Lead Engineer

Most TypeScript Problems Aren't TypeScript Problems

Most TypeScript problems aren’t TypeScript problems. They are data quality problems. That sounds like a TypeScript take, but it is really more of a systems take. It is about what happens when an application receives data from places it does not fully control, tries to render that data into a polished user experience, and realizes the real question is not whether a type exists. The real question is whether the application can trust what that type represents.

I have worked on enough large websites to see the same pattern repeat itself. A team starts with good intentions. They add TypeScript because it is the industry standard, and because they want stronger contracts, safer refactors, and fewer runtime surprises. But I think a lot of developers still think about TypeScript mostly as syntax. It is the thing that tells you whether a prop is a string, whether a function returns a number, or whether you forgot to handle null. Used well, though, TypeScript is one of the places where business rules either become explicit or quietly disappear.

Then the API gets more complicated. The CMS gets more flexible. The design system grows. Requirements shift. Old decisions stick around longer than anyone expected. Eventually, the codebase is full of optional properties, fallback strings, defensive checks, and components that all seem to be asking the same question in slightly different ways: is this data actually safe to use?

At that point, it is easy to start blaming TypeScript. It feels noisy. It feels annoying. It feels like the language is getting in the way. We used to see that friction show up as any, unsafe assertions, or lint ignores across whole files. Now I think it often shows up as optional properties everywhere, like the ground under the application is constantly shifting and nothing is solid. In my experience, TypeScript is usually not the thing creating that mess. TypeScript is exposing the mess that already exists. It is showing you that the application does not have a reliable contract for the data it depends on.

One of the clearest examples I have seen was a shared global copy type used across a large site. It looked like the kind of type you would expect to find in a mature codebase. It had mapped types. It had keyof. It had comments explaining the intent. It looked thoughtful. It looked intentional. It looked like something the team could trust. It also was not enforcing the contract it claimed to enforce.

A simplified version looked something like this:

export type BlogCopy =
  {
    [K in keyof SpecificCopyMap]-?: SpecificCopyMap[K];
  } & {
    [K in Exclude<CopyNamespace, keyof SpecificCopyMap>]?: TextCopy;
  };

export type TextCopy = Record<keyof RequiredCopy | string, string>;

The important part is here:

Record<keyof RequiredCopy | string, string>

At first glance, that looks like it means, “all of the required copy keys, plus maybe some extra string keys.” That was probably the intent. Required copy should be required, but the system should still allow some flexibility for additional keys. The problem is that keyof RequiredCopy | string collapses to string. Once string is part of that union, the specific keys stop mattering. The type becomes effectively this:

Record<string, string>

The required keys were not actually required anymore. The type looked like a contract, but it was really just an open dictionary of strings. The best part, in the most painful possible way, was that this type was used by most of the UI components in the system. Not in one isolated utility. Not in one old feature nobody touched anymore. It was part of the foundation everyone had been building on.

That is the moment where a TypeScript issue stops being a TypeScript issue. The bug was not just that one type was too loose. The bigger issue was that an entire application had adapted around an unreliable contract. Components had learned to protect themselves. Developers had learned not to fully trust the shape of the data. Optional chaining became normal. Empty-string fallbacks became normal. Defensive programming became normal. A pattern that should have been contained at the boundary leaked into the entire UI layer.

The frustrating part is that this usually does not happen because anyone is careless or incapable. It happens because reasonable decisions compound. Teams move fast. They integrate with systems they do not control. They keep the UI stable while the data model changes underneath them. They add one fallback here, one optional prop there, one defensive check somewhere else, and each individual choice makes sense in the moment. Over time, those choices become the architecture.

The codebase quietly trains itself to expect bad data, and that is the real cost. When a component receives a value called copy, product, article, or category, it should not have to investigate whether that value is safe enough to render. A component should not be responsible for understanding which fields are missing because the API is inconsistent, which fields are missing because the CMS allows too much flexibility, and which fields are missing because the design can actually support that scenario.

Those are different kinds of optional. There is a huge difference between “this field is optional because the backend might not send it” and “this field is optional because the user experience has a valid design for when it is absent.” There is also a difference between optional UI and safe data. A component may support a layout without an eyebrow, a badge, or a secondary image, but that does not mean the core data object is allowed to be half-formed. When those ideas get collapsed into the same ?, the application loses context. Everything becomes technically optional, even when the business, the design, or the user experience requires it.

The same thing happens in component APIs when every possible variant gets flattened into one large bag of optional props. The type may be flexible, but the design intent is gone. TypeScript is most useful when it preserves those relationships, not when it lets every possible shape sneak through because modeling the real contract felt inconvenient.

That is how weak API contracts turn into weak application types. The API can tell us what it returned. It cannot always tell us what the interface needs. A CMS can tell us what an editor entered. It cannot always tell us whether that content is complete enough for the component we are about to render. A backend service can tell us what exists in a database. It cannot always tell us the design intention behind how that data should appear on the page. That responsibility lands on the application team.

Whether we like it or not, frontend engineers become shepherds of the data we receive. We qualify it. We clean it. We scrub it. We add defaults where the business has agreed defaults make sense. We reject or fail intentionally when the data is not safe. We turn flexible, inconsistent, external data into stable, trusted application data. That work needs to happen somewhere. The mistake is letting it happen everywhere.

If every component has its own guards, defaults, optional chaining, and fallback logic, the application does not have a data strategy. It has dozens of tiny data strategies. Each one may be reasonable on its own, but together they create a system where nobody knows which assumptions are official and which assumptions were made in a hurry to get a page out the door. That is how a codebase becomes hard to reason about. Not because the components are badly written, but because each component is being asked to solve a problem that should have been solved before the data ever reached it.

The better pattern is to treat external data as unsafe by default. Not bad. Not broken. Unsafe. There is a difference. Unsafe data simply means the application has not qualified it yet. It came from outside the trusted boundary. It might be from a CMS, a third-party API, a backend service, a CSV import, a search index, or some legacy integration that nobody wants to touch. That data may be perfectly valid according to its source, but it is not automatically valid for our application.

I like naming that honestly.

type UnsafeArticle = unknown;

type Article = {
  id: string;
  title: string;
  description: string;
  image: Image;
  href: string;
};

The point is not that every unsafe type literally has to be unknown, although sometimes that is a good forcing function. The point is that the type name should communicate trust. An API response type is not the same thing as an application type. A CMS model is not the same thing as a component contract. A vendor payload is not the same thing as a domain object. ArticleFromApi describes what we received. Article describes what we are willing to render. Those should not automatically be the same type.

The normalizer is the boundary between the two.

function normalizeArticle(input: UnsafeArticle): Article {
  // validate the shape
  // qualify required fields
  // apply meaningful defaults
  // transform source-specific fields
  // remove unused properties for performance
  // fail intentionally when the data is not renderable
}

This is where the messy work belongs. This is where we decide what is required, what can be defaulted, what should be transformed, and what should fail before it reaches the UI. This is also where design intent and business rules need to be made explicit. If the design requires a title, the normalizer should enforce a title. If the design can support a missing eyebrow, the normalizer can default it or model it intentionally. If an image is required for one card layout but not another, that should be represented as a real application decision, not guessed inside a component with another optional chain.

A normalizer should not become a machine for hiding broken contracts. This is where defaults can get dangerous. It is very easy to turn missing data into technically valid data just to make the type pass.

title: input.title ?? ""

That might satisfy TypeScript, but it might also create a broken card, an empty heading, an inaccessible link, or a page with no meaningful content. A default is not just a technical decision. It is a product decision. If the title is required for the design to make sense, defaulting it to an empty string is not safety. It is hiding the fact that the contract was broken.

A safer pattern is to prevent the object from becoming renderable application data in the first place.

function normalizeArticle(input: UnsafeArticle): Article | null {
  const title = getString(input, "title");

  if (!title) {
    return null;
  }

  return {
    id: getRequiredString(input, "id"),
    title,
    description: getString(input, "description") ?? "",
    image: normalizeImage(input),
    href: getRequiredString(input, "href"),
  };
}

Now the downstream code has a clear signal. This item is either a valid Article, or it is not renderable. In a list, invalid items can be filtered before they ever reach the component layer.

const articles = rawArticles
  .map(normalizeArticle)
  .filter((article): article is Article => article !== null);

That is a very different contract than passing an Article with an empty title and hoping every component knows what to do with it. A missing required title should not become a blank string. It should stop the object from becoming trusted renderable data.

This is the part that often gets skipped. Teams will spend time typing the API response, but not enough time defining the shape their application actually wants to work with. Those are separate jobs. The job is not to perfectly type the API response. The job is to create a trusted application contract. A normalizer gives the team one explicit place to make those data-quality decisions, but it only works if the team treats that boundary as real engineering work. It needs ownership. It needs tests. It needs to be easier to trust than the raw data it replaces.

That does not mean better TypeScript always means more complicated TypeScript. In fact, fancy TypeScript can make the problem worse when the team does not understand the contract it is trying to encode. A clever generic or mapped type can look impressive while hiding a weak model underneath. In my experience, most teams do not need more TypeScript wizardry before they get the basics right. They need clearer boundaries, clearer required fields, and clearer relationships between data, design, and business rules.

Once that contract exists, the rest of the system gets simpler. Components stop acting like data janitors. They do not need to know every strange shape the CMS might return. They do not need to know whether a value came from Contentful, Drupal, Shopify, a custom backend, or a search index. They receive the application type and render it.

That separation matters even more when the data source changes. Without a normalization boundary, a new data source becomes a site-wide refactor. Every component that knows too much about the old source has to be updated. Every place that reached into source-specific fields has to be inspected. Every assumption about nullability, naming, formatting, and nesting has to be rediscovered. With a normalization boundary, the application contract can remain stable.

ContentfulArticle
DrupalArticle
ShopifyArticle
SearchIndexArticle
CsvImportedArticle



normalizeArticle()



Article



Components

That is the bigger win. This is not just about making TypeScript quieter or safer. It is about protecting the application from the outside world. A component should not know where its data came from. A component should render a business concept. It should render an Article, a Product, a Category, a User, an Order, or whatever domain object the application has agreed to trust. It should not render the raw shape of whatever system happened to provide the data this year.

APIs change. CMS platforms change. Vendors change. Search indexes change. Backend teams refactor. Companies migrate platforms. Old systems get replaced. New integrations show up. If every one of those changes leaks into the component layer, the application becomes tightly coupled to sources it does not control. But if the application has a stable domain type, a new data source means writing a new normalizer. The Product type does not need to change just because the source changed. The UI components rendering products do not need to care. The source-specific chaos gets absorbed at the boundary where it belongs.

That is architectural stability, and it is also where TypeScript starts to feel good again. When the application types are trustworthy, TypeScript becomes helpful instead of noisy. It tells you when you violated the contract. It helps you refactor. It gives component authors confidence. It lets teams move faster because they are no longer re-litigating the shape of the data in every file.

When the application types are untrustworthy, TypeScript becomes theater. The codebase has types, but developers still do not trust them. The components still defend themselves. The runtime still carries the real burden. The team still learns through bugs which fields were actually required. That can be worse than no TypeScript in some ways, because it creates the feeling of safety without the guarantee of safety.

The lesson I keep coming back to is that TypeScript works best when the team is honest about trust boundaries. External data should be treated as unsafe until it is qualified. API response types should not casually become component prop types. Optional fields should represent real product and design decisions, not uncertainty inherited from another system. Fallbacks should be centralized where possible. Defaults should be meaningful, not random strings added to silence an error.

TypeScript cannot decide what your application should trust. That is our job. The value is not in making every external shape look safe. The value is in choosing the moment where unsafe data becomes a real application contract, and making the rest of the system depend on that.