
Photo by Maxim Berg on Unsplash
I recently discovered a better pattern for guard components in Svelte that solves a minor TypeScript type narrowing issue I'd been working around for some time.
The Problem
In my Svelte apps, I often need to handle data that loads asynchronously on the client side. I use a state pattern: undefined means still loading, null means nothing found, and an actual value means data is loaded.
To reflect those states visually, for example by displaying a good old spinner until data are fetched followed by fading on to the content or a message informing nothing was found, I use a guard component.
If e.g. let's say I'm loading an object called Article, I would be doing something as follows:
<script lang="ts">
import type { Snippet } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import type {Article} from "$lib/types/article";
interface Props {
article: Article | undefined | null;
children: Snippet;
}
let { article, children }: Props = $props();
</script>
{#if article === undefined}
<Spinner>Loading...</Spinner>
{:else if article === null}
<p>Not found.</p>
{:else}
{@render children()}
{/if}This worked fine, but there was a TypeScript quirk: when I used this component, TypeScript couldn't infer that article was guaranteed to be defined when children rendered. So I'd still need type assertions in the consuming code.
For example:
<script lang="ts">
import { onMount } from 'svelte';
import type { Article } from '$lib/types/article';
import Guard from '$lib/Guard.svelte';
import { load } from '$lib/services/article.services';
let article = $state<Article | undefined | null>();
const init = async () => {
article = await load();
};
onMount(init);
</script>
<Guard {article}>
{#if article !== undefined && article !== null}
Loaded: {article.title}
{/if}
</Guard>Those assertions (article !== undefined && article !== null) defeat a bit the whole purpose of having strong types.
The Solution
The solution turned out to be surprisingly simple: use a custom snippet (not children) and pass the loaded value as a parameter.
<script lang="ts">
import type { Snippet } from 'svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import type {Article} from "$lib/types/article";
interface Props {
article: Article | undefined | null;
content: Snippet<[Article]>;
}
let { article, content }: Props = $props();
</script>
{#if article === undefined}
<Spinner>Loading...</Spinner>
{:else if article === null}
<p>Not found.</p>
{:else}
{@render content(article)}
{/if}Which gets implemented by the consumer like:
<Guard {article}>
{#snippet content(article)}
Loaded: {article.title}
{/snippet}
</Guard>Now when I use this guard component, I get full type safety.
Why This Works
By changing from Snippet to Snippet<[Article]> and passing the validated article as a parameter, TypeScript finally understands the flow:
- The guard component checks that
articleis neitherundefinednornull - Only then does it render the snippet, passing
articleas a function parameter - The consuming code receives a properly typed
Articleobject - notArticle | undefined | null
Because the snippet receives article as a parameter only after it passes through the validation checks, TypeScript knows it's safe.
Conclusion
This is one of those small comfort patterns that I really enjoy. It makes my code base cleaner and type safer.
Happy coding ☀️
David