Skill: type-map-keeper
Use this skill when work touches:
- any
TYPESconstant array on a part or builder TYPE_*constants on channel, interaction, component, or embed families- code that dispatches by
type,component_type, or similar runtime discriminator attributeTypedCollectionHelper()orattributePartHelper()calls that consume aTYPESmapChannelBuilder::TYPESalias or equivalent builder-side mirrors
This is subtype-dispatch skill. Load it when a change adds, removes, or reorganizes how the codebase resolves a polymorphic Discord payload into a concrete PHP class.
Goal
Keep type maps as the single source of truth for polymorphic dispatch:
- one constant array decides which class represents each discriminator value
- all materialization sites consume that array instead of branching ad hoc
- builder-side mirrors stay aligned with inbound part maps
- fallback at index
0handles unknown future values safely - public
TYPE_*constants remain stable vocabulary for userland
Read in this order
src/Discord/Parts/Channel/Channel.php—Channel::TYPESandTYPE_*constants, deprecated aliasessrc/Discord/Parts/Interactions/Interaction.php—Interaction::TYPESandTYPE_*constantssrc/Discord/Parts/Embed/Embed.php—Embed::TYPESwith string-keyed discriminatorssrc/Discord/Parts/Channel/Message/Component.php— inboundComponent::TYPESmapsrc/Discord/Builders/Components/ComponentObject.php— outboundComponentObject::TYPESandTYPE_*constantssrc/Discord/Parts/PartTrait.php—attributeTypedCollectionHelper()at line ~530,createOf()at line ~429src/Discord/Builders/ChannelBuilder.php—ChannelBuilder::TYPES = Channel::TYPESalias- Representative event handlers that dispatch through maps:
src/Discord/WebSockets/Events/ChannelCreate.phpsrc/Discord/WebSockets/Events/InteractionCreate.phpsrc/Discord/WebSockets/Events/ThreadListSync.php
Core contract
A TYPES constant is a public const array on the root class of a polymorphic family. Keys are discriminator values (integers or strings) sent by Discord in the type field. Values are fully-qualified class names of concrete subtypes. Index 0 (or equivalent) holds the root class itself as a safe fallback for unrecognized discriminator values.
All code that needs to turn a raw discriminator into a class must look up the map instead of writing its own switch/match/if chain. This keeps subtype knowledge centralized: add one map entry and every dispatch site picks it up automatically.
How type maps work
Map shape
public const TYPES = [
0 => self::class, // fallback
self::TYPE_PING => Ping::class, // known subtypes
self::TYPE_APPLICATION_COMMAND => ApplicationCommand::class,
// ...
];
The discriminator field varies by family: type for channels, interactions, and embeds; type or component_type for components. The attributeTypedCollectionHelper() in PartTrait handles both via $part->type ?? $part->component_type ?? 0.
Dispatch expression
At every materialization site, the pattern is:
$class::TYPES[$data->type ?? 0]
This resolves to either a known concrete class or the fallback root class. Event handlers may add a secondary fallback with ?? Channel::class for families like channels that lack an explicit index 0 entry.
Known type map families
| Root class | Discriminator | Fallback | String keys | Builder mirror |
|---|---|---|---|---|
Channel |
type (int) |
none — events use ?? Channel::class |
no | ChannelBuilder::TYPES |
Interaction |
type (int) |
index 0 => Interaction::class |
no | — |
Embed |
type (string) |
index 0 => Embed::class |
yes ('rich', 'image', …) |
— |
Component (inbound) |
type / component_type (int) |
index 0 via helper |
no | — |
ComponentObject (outbound) |
type (int) |
index 0 via helper |
no | is the builder side |
Component::TYPES and ComponentObject::TYPES are independent arrays that happen to share the same key space and constant names. They map to different class hierarchies — inbound parts vs outbound builders.
Materialization sites
Type maps are consumed in these code paths:
1. Gateway event handlers
Event classes call $this->factory->part(TYPES[$data->type] ?? Fallback::class, ...) to hydrate the correct subtype from raw gateway payloads.
ChannelCreate,ChannelUpdate,ChannelDeleteuseChannelBuilder::TYPES[$data->type] ?? Channel::classThreadCreate,ThreadUpdate,ThreadDelete,ThreadListSyncuse the same channel mapInteractionCreateusesInteraction::TYPES[$data->type ?? 0]
2. attributeTypedCollectionHelper() in PartTrait
Called by mutators like Message::getEmbedsAttribute() and Message::getComponentsAttribute() to build typed collections from raw payload arrays:
$part = $this->createOf($class::TYPES[$part->type ?? $part->component_type ?? 0], $part);
3. attributePartHelper() for single-object dispatch
Used by Section::getAccessoryAttribute(), Label::getComponentAttribute(), and Component::getComponentAttribute() to resolve a single nested object:
return $this->attributePartHelper('accessory', Component::TYPES[$this->attributes['accessory']->type ?? 0]);
4. Resolved data hydration
Resolved::getChannelsAttribute() uses ChannelBuilder::TYPES[$channel->type] ?? Channel::class to hydrate resolved channels from interaction payloads.
5. Builder alias forwarding
ChannelBuilder::TYPES = Channel::TYPES makes the builder a transparent proxy. Event handlers reference ChannelBuilder::TYPES so the builder layer can theoretically override mapping without changing the part, though today both are identical.
Adding a new subtype
Follow this sequence:
- Add
TYPE_*constant on the root class, matching the Discord API integer/string value. - Create the concrete class extending the root part (or sibling base). Keep it in the same namespace family.
- Add the map entry in the root class
TYPESarray, keyed by the new constant. - Update event handlers — any gateway event that hydrates this family will automatically pick up the new entry if it reads
TYPES. Verify the fallback expression still works. - Update builder-side mirror if one exists. For channels,
ChannelBuilder::TYPESis an alias so it picks up changes automatically. For components,ComponentObject::TYPESmust be updated independently. - Update typed collection helpers — if the new subtype appears in a collection context (embeds, components), confirm
attributeTypedCollectionHelper()resolves it. - Add docblocks and
@propertyannotations on parent parts if the subtype surfaces through typed collections. - Update
$fillableand any subtype-specific mutators, repositories, or permission checks. - Preserve deprecated aliases if the new constant replaces an older name.
Builder-side mirrors
The component family has independent inbound and outbound type maps:
- Inbound:
Component::TYPESundersrc/Discord/Parts/Channel/Message/Component.phpmaps to part classes likeActionRow,Button,StringSelect(the inbound representations). - Outbound:
ComponentObject::TYPESundersrc/Discord/Builders/Components/ComponentObject.phpmaps to builder classes likeActionRow,Button,StringSelect(the builder representations).
Both maps share the same TYPE_* integer constants defined on ComponentObject. When Discord adds a new component type, both maps must be extended. Missing one side means either inbound deserialization or outbound construction silently falls back to the wrong class.
For channels, ChannelBuilder::TYPES = Channel::TYPES is a simple alias, so there is no independent map to forget. But if channel builder logic ever diverges, the alias approach should be revisited.
Fallback behavior
Index 0 is the conventional fallback key. When Discord introduces a type value the library does not yet support:
Interaction::TYPES[0]resolves toInteraction::class— a safe generic interactionEmbed::TYPES[0]resolves toEmbed::class— a safe generic embedChannel::TYPEShas no index0, so event handlers append?? Channel::classas explicit fallback
The attributeTypedCollectionHelper() defaults to $part->type ?? $part->component_type ?? 0, which falls through to index 0 when neither field is present.
New families should include an explicit index 0 fallback pointing to the root class. Relying on ?? RootClass::class at every call site is fragile and easy to forget.
Constants as public vocabulary
TYPE_* constants serve as stable API for userland code:
if ($channel->type === Channel::TYPE_GUILD_VOICE) { ... }
Naming convention
- Prefix:
TYPE_(always) - Body: uppercase snake_case matching Discord's enum name (e.g.,
TYPE_GUILD_TEXT,TYPE_APPLICATION_COMMAND) - Constants are defined on the root class, not on subtypes
Deprecated alias preservation
When Discord renames a concept, keep the old constant as a deprecated alias:
/** @deprecated 10.0.0 Use `Channel::TYPE_GUILD_ANNOUNCEMENT` */
public const TYPE_NEWS = self::TYPE_GUILD_ANNOUNCEMENT;
Channel.php carries over a dozen such aliases. Do not remove them without a major version bump. Add @deprecated with the version and migration target.
Smells
Stop if you see:
- new subtype class created but no entry added to the family
TYPESmap - hardcoded
if ($type === 5)branch when a map lookup would work - builder-side component map updated but inbound
Component::TYPESleft stale, or vice versa - event handler using its own local type-to-class mapping instead of the canonical
TYPESconstant attributeTypedCollectionHelper()called with a class whoseTYPESconstant does not exist- missing fallback causing crash on unknown discriminator value from a newer Discord API version
- deprecated constant alias removed without major version bump
TYPE_*constant defined on a subtype instead of the root class
Checklist before commit
-
TYPE_*constant added or updated on the root class -
TYPESmap entry added with constant key → class value - Concrete subtype class created in same namespace family
- Fallback index
0present or explicit?? RootClass::classat all dispatch sites - Builder-side mirror updated if family has one (
ComponentObject::TYPES,ChannelBuilder::TYPES) - Event handlers verified — they should pick up new type automatically via map lookup
-
attributeTypedCollectionHelper()andattributePartHelper()callers verified - Deprecated aliases preserved with
@deprecateddocblock if renaming - Docblocks on root class updated for new constant
- Tests cover the new subtype's basic instantiation and type resolution
Bottom line
Type maps centralize subtype dispatch so that adding a new Discord payload variant is a map entry, not a codebase-wide scavenger hunt through branching logic.