DEV Community

Rina Park
Rina Park

Posted on

Rethinking How to Handle Shape After a Supabase Relation Type Warning

While implementing a screen in a personal project using Next.js (App Router) and Supabase, I ran into an interesting issue.

The screen rendered correctly, but TypeScript was warning me about the relation type.

At first, I thought, “If the screen is rendering, maybe there is no real problem.”

But after digging into it, I realized this was not just a simple type error. It was related to:

  • DB relationship definitions
  • Supabase type inference
  • the boundary between data-fetching functions and the UI
  • designing the shape used in the UI

In this post, I want to organize what I observed, how I reasoned about it, and what I learned.


1. What I started with

On the screen, I was fetching data through a data-fetching function, then passing it to the component.

return <TaskDetailContent task={task} statuses={statuses} />;
Enter fullscreen mode Exit fullscreen mode

In page.tsx, I intentionally kept the logic minimal and limited its responsibility to traffic control:

  • fetch the data
  • redirect to 404 (notFound) if it does not exist
  • render the component if it does

2. The problem

The screen itself rendered fine, but TypeScript showed the following warning on the page side:

Type '{ ... }' is not assignable to type 'TaskDetail'.
Types of property 'status' are incompatible.
Type 'TaskStatusOption[]' is not assignable to type 'TaskStatusOption'.
Enter fullscreen mode Exit fullscreen mode

In other words, there was a mismatch:

the component wanted status as a single object, but TypeScript thought it might come back as an array.

So the situation was:

  • The UI was working (at least with the actual data returned at that point, it was close to the shape the UI expected)
  • But TypeScript flagged the structural uncertainty and issued a warning

3. What I confirmed from the logs

So instead of guessing, I checked what Supabase was actually returning.

console.log(JSON.stringify(data, null, 2));
Enter fullscreen mode Exit fullscreen mode

Part of the logged result looked like this:

{
  "status": {
    "id": "...",
    "key": "pending",
    "name": "Pending"
  },
  "project": null,
  "category": null
}
Enter fullscreen mode Exit fullscreen mode

At least in this case:

  • status was a single object, not an array
  • project was null
  • category was null

So the actual runtime data was already quite close to what the UI expected.


4. The core issue

What I realized here was this:

the shape observed at runtime
and the shape TypeScript was trying to guarantee
were not aligned.

Supabase infers types based on DB relationship definitions.
So if the relation shape looks ambiguous, you need to think about things like:

  • foreign key constraints in the DB
  • relationship definitions
  • whether unique constraints exist
  • how the query is written
  • whether generated types are being used

In this case, the logs showed a single object, while TypeScript still treated the relation as if it might be an array.

So rather than saying TypeScript was “wrong,” it was probably signaling this:

this relationship may not be safely fixed to one shape at the code level yet


5. What I did

My approach this time was to explicitly construct the return shape inside the data-fetching function.

if (error || !data) {
  return null;
}

const status = pickOne(data.status);
const category = pickOne(data.category);
const rawProject = pickOne(data.project);

if (!status) {
  return null;
}

const projectCategory = rawProject
  ? pickOne(rawProject.category)
  : null;

const project: TaskProject = rawProject
  ? {
      id: rawProject.id,
      name: rawProject.name,
      category: projectCategory
        ? {
            id: projectCategory.id,
            name: projectCategory.name,
          }
        : null,
    }
  : null;

return {
  id: data.id,
  title: data.title,
  title_original: data.title_original,
  description: data.description,
  due_date: data.due_date,
  created_at: data.created_at,
  completed_at: data.completed_at,
  status: {
    id: status.id,
    key: status.key,
    name: status.name,
  },
  project,
  category: category
    ? {
        id: category.id,
        name: category.name,
      }
    : null,
};
Enter fullscreen mode Exit fullscreen mode

This gave me a few benefits:

  • Explicitly defines the TaskDetail shape expected by the UI.
  • Page.tsx and its components are shielded from internal DB relation structures.
  • Clearly marks the boundary between the "DB-side shape" and the "App-side shape."

To achieve this safely, I used a utility called pickOne to normalize values that could be either an array or a single object:

function pickOne<T>(value: T | T[] | null | undefined): T | null {
  if (Array.isArray(value)) {
    return value[0] ?? null;
  }

  return value ?? null;
}
Enter fullscreen mode Exit fullscreen mode

This wasn't just about silencing TypeScript. It was about creating a normalization layer to ensure the UI only receives stable, predictable data.


6. What I learned

6.1. Shape is not just a returned result. It is also a design decision.

How the DB or API returns data is important, but that is a different question from how the application chooses to handle it.

What I felt here was that:

using the returned shape as-is and treating that shape as the canonical shape of the app are not the same thing.

6.2. Runtime and TypeScript have different responsibilities

  • runtime = the actual data returned at that moment
  • TypeScript = whether the code is safe to treat as fixed, including future cases

It is completely possible for something to look correct at runtime while still being uncertain at the type level.

6.3. TypeScript warnings can be a trigger to revisit the design

At first, I was frustrated.
The screen was rendering, so why was TypeScript complaining?

But in hindsight, TypeScript was asking a reasonable design question:

is this relationship shape really safe to treat as fixed?

6.4. “It works” does not necessarily mean “it is correct”

That was exactly the situation here.

  • The UI rendered
  • But the type contract around the relation was still ambiguous

Catching that mismatch early was a valuable lesson.


7. What I want to do next

The UI is now stable, but I do not intend to treat this approach as the final solution.

For now, I prioritized moving the screen implementation forward and chose to make the shape explicit inside the data-fetching function.

At the same time, this experience made it clear that I still need to organize these areas more carefully:

  • DB relationship definitions
  • Supabase type inference
  • the boundary between data-fetching functions and the UI
  • where the shape should be fixed in the application

So this was not just a temporary patch.
It was also valuable because it showed me where the deeper improvements need to happen.

Going forward, I want to think not only about how data is returned, but also about which layer should define and own the canonical shape.

Top comments (0)