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} />;
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'.
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));
Part of the logged result looked like this:
{
"status": {
"id": "...",
"key": "pending",
"name": "Pending"
},
"project": null,
"category": null
}
At least in this case:
-
statuswas a single object, not an array -
projectwas null -
categorywas 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,
};
This gave me a few benefits:
- Explicitly defines the
TaskDetailshape expected by the UI. -
Page.tsxand 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;
}
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)