Problem
The inCachedGuild guard promises type narrowing via GuildInteraction<T> = T & { guild: Guild; ... }, but TypeScript's intersection types cannot override class getters. When BaseInteraction defines get guild(): Guild | null, the intersection & { guild: Guild } doesn't narrow the return type — TypeScript resolves the getter's declared return type from the class, not the intersection member.
This means in spark actions, interaction.guild is still Guild | null even after the guard has validated it at runtime:
export const ping = defineCommand({
command: builder,
guards: [g.inCachedGuild],
action: async (interaction) => {
interaction.guild; // Still Guild | null — TS doesn't narrow getters via intersection
},
});
This affects guild, member, guildId, and channel — all defined as getters on BaseInteraction.
Why discord.js <'cached'> doesn't help here
discord.js solves this at the class level with Interaction<'cached'> where guild is typed as Guild (non-null) via CacheTypeReducer. However, the guard system's NarrowedBy type utility is fundamentally intersection-based (TBase & TOut). Switching to discord.js's generic parameter approach would require rethinking NarrowedBy with conditional type mapping — a deep architectural change that doesn't compose well with the current guard system.
Proposed fix
Add a typed accessor helper to in-cached-guild.ts that bridges the gap:
/** Extracts the guaranteed-non-null guild from a guard-narrowed interaction. */
export function guildFrom(interaction: GuildInteraction): Guild {
return interaction.guild as Guild;
}
Usage
import * as g from '@/guards/built-in';
export const ping = defineCommand({
command: builder,
guards: [g.inCachedGuild],
action: async (interaction) => {
const guild = g.guildFrom(interaction);
// guild: Guild — no null, no assertion
await interaction.reply(`Server: ${guild.name}`);
},
});
The function signature constrains the input to GuildInteraction, so it can't be called without the guard in the chain — TypeScript errors if inCachedGuild is missing from the guards array.
Scope
- Add
guildFrom() helper to in-cached-guild.ts
- Consider similar helpers for
member and channel if needed (memberFrom, channelFrom)
- Export from
@/guards/built-in barrel
- Update docs/guards.md with the pattern
- Add tests
Root cause
This is a TypeScript language limitation, not a bug in the guard system. See TypeScript#33014 for background on intersection types not narrowing accessors/getters.
Problem
The
inCachedGuildguard promises type narrowing viaGuildInteraction<T> = T & { guild: Guild; ... }, but TypeScript's intersection types cannot override class getters. WhenBaseInteractiondefinesget guild(): Guild | null, the intersection& { guild: Guild }doesn't narrow the return type — TypeScript resolves the getter's declared return type from the class, not the intersection member.This means in spark actions,
interaction.guildis stillGuild | nulleven after the guard has validated it at runtime:This affects
guild,member,guildId, andchannel— all defined as getters onBaseInteraction.Why discord.js
<'cached'>doesn't help herediscord.js solves this at the class level with
Interaction<'cached'>whereguildis typed asGuild(non-null) viaCacheTypeReducer. However, the guard system'sNarrowedBytype utility is fundamentally intersection-based (TBase & TOut). Switching to discord.js's generic parameter approach would require rethinkingNarrowedBywith conditional type mapping — a deep architectural change that doesn't compose well with the current guard system.Proposed fix
Add a typed accessor helper to
in-cached-guild.tsthat bridges the gap:Usage
The function signature constrains the input to
GuildInteraction, so it can't be called without the guard in the chain — TypeScript errors ifinCachedGuildis missing from the guards array.Scope
guildFrom()helper toin-cached-guild.tsmemberandchannelif needed (memberFrom,channelFrom)@/guards/built-inbarrelRoot cause
This is a TypeScript language limitation, not a bug in the guard system. See TypeScript#33014 for background on intersection types not narrowing accessors/getters.