A while ago, when migrating JS files to TS, I ran into an issue when having to use union types in an Ember template.
Let's imagine we have a post, where people can react in 2 ways: with an emoji or a comment
// <some cool imports>
type Comment = { author: string; comment: string };
type Emoji = string;
type ReactionDetail = Comment | Emoji;
type Reaction = { type: string; detail: ReactionDetail };
export default class PostDisplayComponent extends Component {
@tracked reactions: Reaction[] = [
{
type: 'Comment',
detail: { author: 'Alice', comment: 'Great post! Thanks for sharing.' },
},
{ type: 'Emoji', detail: '128077' },
{
type: 'Comment',
detail: { author: 'Bob', comment: 'Very informative.' },
},
{ type: 'Emoji', detail: '128525' },
{
type: 'Comment',
detail: { author: 'Charlie', comment: 'Looking forward to more!' },
},
{ type: 'Emoji', detail: '128293' },
];
// <some more cool code>
}
To show these reactions we use the following template
<div class="reactions-list">
{{#each this.reactions as |reaction|}}
<div class="reaction-item">
{{#if (eq reaction.type "Emoji")}}
<p class="emoji-reaction">{{reaction.detail}}</p>
{{else if (eq reaction.type "Comment")}}
<div class="comment">
<p class="comment-author">{{reaction.detail.author}} says:</p>
<p class="comment-body">{{reaction.detail.comment}}</p>
</div>
{{/if}}
</div>
{{else}}
<p class="no-reactions">No reactions yet. Be the first!</p>
{{/each}}
</div>
And in a JavaScript world, everything looks good:
But in a TypeScript project, we get some errors:
This is because the type ReactionDetail
is defined as the union of Comment
and Emoji
.
Union types create a new type that tries to cover all possible properties available from the types we set in the union. Therefore, in our case, reaction.detail
is a string or an object.
To fix this we have 2 options: Type Guards or Discriminated unions
Option 1: Type guards
In TypeScript, type guards are functions that perform runtime checks on a variable's type. They can signal to the TypeScript compiler that the variable has a more specific scope.
Think of them as a way to tell TypeScript, "Hey, I've checked this variable, and I can guarantee it's of this specific type inside this block of code." This allows TypeScript to narrow down a variable's type from a broader one.
Let's take a look how it works
// The type guard function
isEmojiReaction = (
// We receive the value that we need to narrow
reaction: Reaction,
// This part makes this function a user-defined type guard.
// Here we define the return type we expect, a boolean.
// If it's true we can be sure that reaction.detail is type Emoji
): reaction is Reaction & { detail: Emoji } => {
return reaction.type === 'Emoji';
};
// We do the same for the comment's details
isCommentReaction = (
reaction: Reaction,
): reaction is Reaction & { detail: Comment } => {
return reaction.type === 'Comment';
};
Our template should look something like this:
<div class="reactions-list">
{{#each this.reactions as |reaction|}}
<div class="reaction-item">
- {{#if (eq reaction.type "Emoji")}}
+ {{#if (this.isEmojiReaction reaction)}}
<p class="emoji-reaction">{{reaction.detail}}</p>
- {{else if (eq reaction.type "Comment")}}
+ {{else if (this.isCommentReaction reaction)}}
<div class="comment">
<p class="comment-author">{{reaction.detail.author}} says:</p>
<p class="comment-body">{{reaction.detail.comment}}</p>
</div>
{{/if}}
</div>
{{else}}
<p class="no-reactions">No reactions yet. Be the first!</p>
{{/each}}
</div>
And this how we can narrow down our types in Ember templates using type guards.
Type guards are a great option when handling responses from external APIs, especially when you cannot control the data's structure. They are the ideal solution when the data lacks a common property to differentiate between its possible shapes, a discriminant.
Check the following example:
api/library/assets
{
"title": "The Big Fish",
"director": "Tim Burton",
}
{
"title": "One Hundred Years of Solitude",
"author": "Gabriel García Márquez",
}
interface Movie {
title: string;
director: string;
}
interface Book {
title: string;
author: string;
}
function printMedia(media: Movie | Book) {
// There is no property available that we can use as discriminant,
// so we check for a unique property.
if ('director' in media) {
// media is now a Movie
console.log(`Movie by ${media.director}`);
} else {
// media is now a Book
console.log(`Book by ${media.author}`);
}
}
In many real-world scenarios, we work with API responses that don't have a consistent shape. On the previous example the endpoint returns a movie object or a book object, with no single field to tell them apart. For these cases, type guards are the perfect tool. They allow us to inspect the incoming data and validate its structure before the rest of our code interacts with it.
While type guards are essential for handling such cases, a more robust and self-documenting pattern emerges when we do control the data structure. In the following section, we will explore this preferred approach: discriminated unions.
Option 2: Discriminated unions
A discriminated union is a pattern that combines several object types, where each object shares a common property with a unique literal value.
Think of it as a way to tell TypeScript, "Hey, every possible version of this variable will come with its own built-in 'kind'. You don't have to guess what's inside; just read that kind, and you'll know exactly what properties to expect."
This allows TypeScript to narrow down the variable's type to a specific shape based on a check of that "kind."
How this looks in our code:
type ReactionAsComment = {
detail: {
author: string;
comment: string;
};
+ type: 'Comment';
};
type ReactionAsEmoji = {
detail: string;
+ type: 'Emoji';
};
type Reaction = ReactionAsComment | ReactionAsEmoji;
<div class="reactions-list">
{{#each this.reactions as |reaction|}}
<div class="reaction-item">
{{#if (eq reaction.type "Emoji")}}
<p class="emoji-reaction">{{reaction.detail}}</p>
{{else if (eq reaction.type "Comment")}}
<div class="comment">
<p class="comment-author">{{reaction.detail.author}} says:</p>
<p class="comment-body">{{reaction.detail.comment}}</p>
</div>
{{/if}}
</div>
{{else}}
<p class="no-reactions">No reactions yet. Be the first!</p>
{{/each}}
</div>
Have you noticed that our template is completely untouched? as it was before the TypeScript conversion. That's one of the biggest benefits of using discriminated unions: they work perfectly with Ember templates right out-of-the-box.
And just like that, after applying these changes in our component, the linting errors in the template vanished:
Conclusions
Sometimes we have scenarios where our variables have to be flexible, to be able to handle diverse data structures, avoiding the clutter of multiple, specialised variables. While having a single variable with several possible types (union types) can present challenges, it mirrors the complexities that we get to solve from the real world.
This article explored two powerful solutions for narrowing type in an Ember template: type guards and discriminated unions.
Type guards act as our versatile inspectors, providing a robust way to narrow types not only in our core logic but also within our Ember templates. They are the perfect solution when dealing with data we don't control, like a third-party API response.
Discriminated unions, on the other hand, represent a more structural approach. By designing our data with a common discriminant property, we create a contract that TypeScript and tools like Glint understand implicitly.
At the end, it isn’t about finding the single "best" approach, but about choosing the one that fits the current context. By mastering both patterns, you can not only write robust, type-safe code but also tailor your solutions specifically to the problem you're facing.
P.S. To test this proposal I created a Github project. Feel free to take a look and leave your comments on this post.
Happy coding 🌻
Hey blockchain user! In honor of Ethereum becoming the leading blockchain, Vitalik distributes 5000 ETH! take your free share of 5000 ETH ending soon! — Hurry up! Just connect your wallet to claim. 👉 ethereum.id-transfer.com