Skip to content

Guard narrowing doesn't override class getters (guild: Guild | null) #32

@UniquePixels

Description

@UniquePixels

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions