diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f1d33f58a..598820ebc 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -27,6 +27,7 @@ pnpm-lock.yaml @discordjs/core
/packages/proxy-container/ @discordjs/proxy
/packages/rest/ @discordjs/rest
/packages/scripts/ @discordjs/scripts
+/packages/structures/ @discordjs/structures
/packages/ui/ @discordjs/ui
/packages/util/ @discordjs/util
/packages/voice/ @discordjs/core
diff --git a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml
index 9a70b864b..7427f983b 100644
--- a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml
@@ -24,6 +24,7 @@ body:
- proxy
- proxy-container
- rest
+ - structures
- ui
- util
- voice
diff --git a/.github/ISSUE_TEMPLATE/03-feature_request.yml b/.github/ISSUE_TEMPLATE/03-feature_request.yml
index 5c2d330be..d6ffc4074 100644
--- a/.github/ISSUE_TEMPLATE/03-feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/03-feature_request.yml
@@ -26,6 +26,7 @@ body:
- proxy
- proxy-container
- rest
+ - structures
- ui
- util
- voice
diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml
index ff7e027a8..3e6a8c384 100644
--- a/.github/issue-labeler.yml
+++ b/.github/issue-labeler.yml
@@ -37,6 +37,9 @@ packages:proxy-container:
packages:rest:
- "### Which (application|package|application or package) is this (bug
report|feature request) for\\?\\n\\nrest\\n"
+packages:structures:
+ - "### Which (application|package|application or package) is this (bug
+ report|feature request) for\\?\\n\\nstructures\\n"
packages:ui:
- "### Which (application|package|application or package) is this (bug
report|feature request) for\\?\\n\\ui\\n"
diff --git a/.github/labeler.yml b/.github/labeler.yml
index c60f0e768..d4214ce85 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -78,6 +78,11 @@ packages:rest:
- any-glob-to-any-file:
- packages/rest/*
- packages/rest/**/*
+packages:structures:
+ - changed-files:
+ - any-glob-to-any-file:
+ - packages/structures/*
+ - packages/structures/**/*
packages:ui:
- changed-files:
- any-glob-to-any-file:
diff --git a/.github/labels.yml b/.github/labels.yml
index 07e5c8238..62ecd2f4e 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -80,6 +80,8 @@
color: fbca04
- name: packages:rest
color: fbca04
+- name: packages:structures
+ color: fbca04
- name: packages:ui
color: fbca04
- name: packages:util
diff --git a/.github/workflows/deprecate-version.yml b/.github/workflows/deprecate-version.yml
index 111a1d627..c2900400b 100644
--- a/.github/workflows/deprecate-version.yml
+++ b/.github/workflows/deprecate-version.yml
@@ -17,6 +17,7 @@ on:
- '@discordjs/next'
- '@discordjs/proxy'
- '@discordjs/rest'
+ - '@discordjs/structures'
- '@discordjs/util'
- '@discordjs/voice'
- '@discordjs/ws'
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 7dbfc58ec..714f82e12 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -86,7 +86,7 @@ jobs:
- name: Build docs with main api-extractor
if: ${{ inputs.ref && inputs.ref != 'main' }}
run: |
- declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "util" "voice" "ws")
+ declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws")
for PACKAGE in "${PACKAGES[@]}"; do
cd "packages/${PACKAGE}"
sed -i 's!https://github.com/discordjs/discord.js/tree/main!https://github.com/discordjs/discord.js/tree/${{ inputs.ref }}!' api-extractor.json
@@ -219,7 +219,7 @@ jobs:
- name: Move docs to correct directory
if: ${{ env.REF_TYPE == 'branch' }}
run: |
- declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "util" "voice" "ws")
+ declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws")
for PACKAGE in "${PACKAGES[@]}"; do
if [[ "${PACKAGE}" == "discord.js" ]]; then
mkdir -p "out/${PACKAGE}"
diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml
index 5dc2e4af9..6742d1412 100644
--- a/.github/workflows/publish-dev.yml
+++ b/.github/workflows/publish-dev.yml
@@ -28,6 +28,8 @@ jobs:
folder: 'proxy'
- package: '@discordjs/rest'
folder: 'rest'
+ - package: '@discordjs/structures'
+ folder: 'structures'
- package: '@discordjs/util'
folder: 'util'
- package: '@discordjs/voice'
diff --git a/apps/website/package.json b/apps/website/package.json
index 50ee4ecc0..dc1a9de6d 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -5,7 +5,7 @@
"description": "Imagine a bot... the most popular way to build discord bots",
"private": true,
"scripts": {
- "build:copy_readme": "cpy \"../../packages/(discord.js|brokers|builders|collection|core|formatters|next|proxy|rest|util|voice|ws)/README.md\" \"src/assets/readme\" --rename='home-{{basename}}'",
+ "build:copy_readme": "cpy \"../../packages/(discord.js|brokers|builders|collection|core|formatters|next|proxy|rest|structures|util|voice|ws)/README.md\" \"src/assets/readme\" --rename='home-{{basename}}'",
"build:check": "tsc --noEmit",
"build:local": "cross-env NEXT_PUBLIC_LOCAL_DEV=true pnpm run build:prod",
"build:prod": "pnpm run build:copy_readme && pnpm run build:next",
diff --git a/apps/website/src/util/constants.ts b/apps/website/src/util/constants.ts
index 46d17f043..36619c05e 100644
--- a/apps/website/src/util/constants.ts
+++ b/apps/website/src/util/constants.ts
@@ -9,6 +9,7 @@ export const PACKAGES = [
{ name: 'next' },
{ name: 'proxy' },
{ name: 'rest' },
+ { name: 'structures' },
{ name: 'util' },
{ name: 'voice' },
{ name: 'ws' },
diff --git a/eslint.config.js b/eslint.config.js
index 7dbe13b6d..461563d6d 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -244,6 +244,14 @@ export default tseslint.config(
'unicorn/prefer-node-protocol': 0,
},
},
+ {
+ files: [`packages/structures/**/*${commonFiles}`],
+ rules: {
+ '@typescript-eslint/no-empty-interface': 0,
+ '@typescript-eslint/no-empty-object-type': 0,
+ '@typescript-eslint/no-unsafe-declaration-merging': 0,
+ },
+ },
{
files: [`packages/voice/**/*${commonFiles}`],
rules: {
diff --git a/packages/actions/src/uploadCoverage/action.yml b/packages/actions/src/uploadCoverage/action.yml
index 816e89efe..1434d8fc9 100644
--- a/packages/actions/src/uploadCoverage/action.yml
+++ b/packages/actions/src/uploadCoverage/action.yml
@@ -88,6 +88,15 @@ runs:
flags: rest
token: ${{ inputs.CODECOV_TOKEN }}
+ - name: Upload Structures Coverage
+ if: ${{ hashFiles('packages/structures/coverage/cobertura-coverage.xml') != '' }}
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./packages/structures/coverage/cobertura-coverage.xml
+ disable_search: true
+ flags: structures
+ token: ${{ inputs.CODECOV_TOKEN }}
+
- name: Upload Util Coverage
if: ${{ hashFiles('packages/util/coverage/cobertura-coverage.xml') != '' }}
uses: codecov/codecov-action@v4
diff --git a/packages/scripts/src/shared.ts b/packages/scripts/src/shared.ts
index 32a8a001b..a8dff5021 100644
--- a/packages/scripts/src/shared.ts
+++ b/packages/scripts/src/shared.ts
@@ -10,6 +10,7 @@ export const PACKAGES = [
'next',
'proxy',
'rest',
+ 'structures',
'util',
'voice',
'ws',
diff --git a/packages/structures/.cliff-jumperrc.json b/packages/structures/.cliff-jumperrc.json
new file mode 100644
index 000000000..62fb98f7c
--- /dev/null
+++ b/packages/structures/.cliff-jumperrc.json
@@ -0,0 +1,6 @@
+{
+ "name": "structures",
+ "org": "discordjs",
+ "packagePath": "packages/structures",
+ "identifierBase": false
+}
diff --git a/packages/structures/.gitignore b/packages/structures/.gitignore
new file mode 100644
index 000000000..90500960f
--- /dev/null
+++ b/packages/structures/.gitignore
@@ -0,0 +1,28 @@
+# Packages
+node_modules
+
+# Log files
+logs
+*.log
+npm-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Env
+.env
+
+# Dist
+dist
+dist-docs
+
+# Docs
+docs/**/*
+!docs/README.md
+
+# Miscellaneous
+.turbo
+.tmp
+coverage
diff --git a/packages/structures/.lintstagedrc.js b/packages/structures/.lintstagedrc.js
new file mode 100644
index 000000000..c46f610c8
--- /dev/null
+++ b/packages/structures/.lintstagedrc.js
@@ -0,0 +1,2 @@
+/** @type {import('lint-staged').Config} */
+module.exports = require('../../.lintstagedrc.json');
diff --git a/packages/structures/.prettierignore b/packages/structures/.prettierignore
new file mode 100644
index 000000000..9aef7953d
--- /dev/null
+++ b/packages/structures/.prettierignore
@@ -0,0 +1,7 @@
+.turbo
+coverage
+dist
+dist-docs
+docs/docs.api.json
+CHANGELOG.md
+tsup.config.bundled*
diff --git a/packages/structures/.prettierrc.js b/packages/structures/.prettierrc.js
new file mode 100644
index 000000000..f723230a0
--- /dev/null
+++ b/packages/structures/.prettierrc.js
@@ -0,0 +1,2 @@
+/** @type {import('prettier').Config} */
+module.exports = require('../../.prettierrc.json');
diff --git a/packages/structures/LICENSE b/packages/structures/LICENSE
new file mode 100644
index 000000000..ee2a64b25
--- /dev/null
+++ b/packages/structures/LICENSE
@@ -0,0 +1,191 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright 2023 Noel Buechler
+ Copyright 2023 Chai Kohen
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/structures/README.md b/packages/structures/README.md
new file mode 100644
index 000000000..73af52f5a
--- /dev/null
+++ b/packages/structures/README.md
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## About
+
+`@discordjs/structures` is a low level wrapper around Discord JSON Objects, meant to be a foundation to build upon in a higher level library.
+
+## Installation
+
+**Node.js 22.12.0 or newer is required.**
+
+```sh
+npm install @discordjs/structures
+yarn add @discordjs/structures
+pnpm add @discordjs/structures
+bun add @discordjs/structures
+```
+
+## Links
+
+- [Website][website] ([source][website-source])
+- [Documentation][documentation]
+- [Guide][guide] ([source][guide-source])
+ Also see the v13 to v14 [Update Guide][guide-update], which includes updated and removed items from the library.
+- [discord.js Discord server][discord]
+- [Discord Developers Discord server][discord-developers]
+- [GitHub][source]
+- [npm][npm]
+- [Related libraries][related-libs]
+
+## Contributing
+
+Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the
+[documentation][documentation].
+See [the contribution guide][contributing] if you'd like to submit a PR.
+
+## Help
+
+If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle nudge in the right direction, please don't hesitate to join our official [discord.js Server][discord].
+
+[website]: https://discord.js.org
+[website-source]: https://github.com/discordjs/discord.js/tree/main/apps/website
+[documentation]: https://discord.js.org/docs/packages/structures/stable
+[guide]: https://discordjs.guide/
+[guide-source]: https://github.com/discordjs/guide
+[guide-update]: https://discordjs.guide/additional-info/changes-in-v14.html
+[discord]: https://discord.gg/djs
+[discord-developers]: https://discord.gg/discord-developers
+[source]: https://github.com/discordjs/discord.js/tree/main/packages/structures
+[npm]: https://www.npmjs.com/package/@discordjs/structures
+[related-libs]: https://discord.com/developers/docs/topics/community-resources#libraries
+[contributing]: https://github.com/discordjs/discord.js/blob/main/.github/CONTRIBUTING.md
diff --git a/packages/structures/__tests__/Mixin.test.ts b/packages/structures/__tests__/Mixin.test.ts
new file mode 100644
index 000000000..2eaa082b9
--- /dev/null
+++ b/packages/structures/__tests__/Mixin.test.ts
@@ -0,0 +1,98 @@
+import { describe, test, expect } from 'vitest';
+import { kData, kPatch } from '../src/utils/symbols.js';
+import type { APIData } from './mixinClasses.js';
+import { Base, Mixed, MixedWithExtended } from './mixinClasses.js';
+
+describe('Mixin function', () => {
+ const data: APIData = {
+ id: '1',
+ property1: 23,
+ };
+
+ test('Mixed class has all getters', () => {
+ const instance = new Mixed(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.property1).toBe(data.property1);
+ expect(instance.property2).toBe(data.property2);
+ });
+
+ test('Mixed class has all methods', () => {
+ const instance = new Mixed(data);
+ expect(instance.getId()).toBe(data.id);
+ expect(instance.getProperty1()).toBe(data.property1);
+ expect(instance.getProperty2()).toBe(data.property2);
+ expect(instance.getProperties()).toEqual({
+ property1: data.property1,
+ property2: data.property2,
+ });
+ });
+
+ test('Mixed with extended class has all getters', () => {
+ const instance = new MixedWithExtended(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.property1).toBe(data.property1);
+ expect(instance.property2).toBe(data.property2);
+ expect(instance.isExtended).toBe(true);
+ });
+
+ test('Mixed with extended class has all methods', () => {
+ const instance = new MixedWithExtended(data);
+ expect(instance.getId()).toBe(data.id);
+ expect(instance.getProperty1()).toBe(data.property1);
+ expect(instance.getProperty2()).toBe(data.property2);
+ expect(instance.getProperties()).toEqual({
+ property1: data.property1,
+ property2: data.property2,
+ });
+ });
+
+ test('Mixed class calls construct methods on construct', () => {
+ const instance1 = new Mixed(data);
+ const instance2 = new MixedWithExtended(data);
+ expect(instance1.constructCalled).toBe(true);
+ expect(instance2.constructCalled).toBe(true);
+ });
+
+ test('Mixed class respects mixin data optimizations', () => {
+ expect(typeof Object.getOwnPropertyDescriptor(Mixed.DataTemplate, 'mixinOptimize')?.set).toBe('function');
+ const missingOptimizedInstance = new Mixed(data);
+ const alreadyOptimizedInstance = new Mixed({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
+ const baseOptimizedInstance = new Base({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
+
+ expect(missingOptimizedInstance.baseOptimize).toBe(null);
+ expect(missingOptimizedInstance.mixinOptimize).toBe(null);
+ // Setters pass this
+ expect('baseOptimize' in missingOptimizedInstance[kData]).toBe(true);
+ expect('mixinOptimize' in missingOptimizedInstance[kData]).toBe(true);
+ expect(missingOptimizedInstance[kData].baseOptimize).toBeUndefined();
+ expect(missingOptimizedInstance[kData].mixinOptimize).toBeUndefined();
+
+ expect(alreadyOptimizedInstance.baseOptimize).toBe(true);
+ expect(alreadyOptimizedInstance.mixinOptimize).toBe(true);
+ // Setters pass this
+ expect('baseOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
+ expect('mixinOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
+ expect(alreadyOptimizedInstance[kData].baseOptimize).toBeUndefined();
+ expect(alreadyOptimizedInstance[kData].mixinOptimize).toBeUndefined();
+ expect(alreadyOptimizedInstance.toJSON()).toEqual({ ...data, mixinOptimize: 'true', baseOptimize: 'true' });
+
+ alreadyOptimizedInstance[kPatch]({ mixinOptimize: '', baseOptimize: '' });
+
+ expect(alreadyOptimizedInstance.baseOptimize).toBe(false);
+ expect(alreadyOptimizedInstance.mixinOptimize).toBe(false);
+ // Setters pass this
+ expect('baseOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
+ expect('mixinOptimize' in alreadyOptimizedInstance[kData]).toBe(true);
+ expect(alreadyOptimizedInstance[kData].baseOptimize).toBeUndefined();
+ expect(alreadyOptimizedInstance[kData].mixinOptimize).toBeUndefined();
+
+ // Ensure mixin optimizations don't happen on base (ie overwritten DataTemplate)
+ expect(baseOptimizedInstance.baseOptimize).toBe(true);
+ expect('mixinOptimize' in baseOptimizedInstance).toBe(false);
+ // Setters pass this
+ expect('baseOptimize' in baseOptimizedInstance[kData]).toBe(true);
+ expect('mixinOptimize' in baseOptimizedInstance[kData]).toBe(true);
+ expect(baseOptimizedInstance[kData].baseOptimize).toBeUndefined();
+ expect(baseOptimizedInstance[kData].mixinOptimize).toBe('true');
+ });
+});
diff --git a/packages/structures/__tests__/Structure.test.ts b/packages/structures/__tests__/Structure.test.ts
new file mode 100644
index 000000000..29a2390ef
--- /dev/null
+++ b/packages/structures/__tests__/Structure.test.ts
@@ -0,0 +1,65 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+import { DataTemplatePropertyName, OptimizeDataPropertyName, Structure } from '../src/Structure.js';
+import { kData, kPatch } from '../src/utils/symbols.js';
+
+describe('Base Structure', () => {
+ const data = { test: true, patched: false, removed: true };
+ let struct: Structure;
+ beforeEach(() => {
+ // @ts-expect-error Structure constructor is protected
+ struct = new Structure(data);
+ // @ts-expect-error Structure.DataTemplate is protected
+ Structure.DataTemplate = {};
+ });
+
+ test('Data reference is not identical (clone via Object.assign)', () => {
+ expect(struct[kData]).not.toBe(data);
+ expect(struct[kData]).toEqual(data);
+ });
+
+ test('Remove properties via template (constructor)', () => {
+ // @ts-expect-error Structure.DataTemplate is protected
+ Structure.DataTemplate = { set removed(_) {} };
+ // @ts-expect-error Structure constructor is protected
+ const templatedStruct: Structure = new Structure(data);
+ expect(templatedStruct[kData].removed).toBe(undefined);
+ // Setters still exist and pass "in" test unfortunately
+ expect('removed' in templatedStruct[kData]).toBe(true);
+ expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
+ });
+
+ test('patch clones data and updates in place', () => {
+ const dataBefore = struct[kData];
+ const patched = struct[kPatch]({ patched: true });
+ expect(patched[kData].patched).toBe(true);
+ // Patch in place
+ expect(struct[kData]).toBe(patched[kData]);
+ // Clones
+ expect(dataBefore.patched).toBe(false);
+ expect(dataBefore).not.toBe(patched[kData]);
+ });
+
+ test('Remove properties via template ([kPatch])', () => {
+ // @ts-expect-error Structure.DataTemplate is protected
+ Structure.DataTemplate = { set removed(_) {} };
+ // @ts-expect-error Structure constructor is protected
+ const templatedStruct: Structure = new Structure(data);
+ templatedStruct[kPatch]({ removed: false });
+ expect(templatedStruct[kData].removed).toBe(undefined);
+ // Setters still exist and pass "in" test unfortunately
+ expect('removed' in templatedStruct[kData]).toBe(true);
+ expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
+ });
+
+ test('toJSON clones but retains data equality', () => {
+ const json = struct.toJSON();
+ expect(json).not.toBe(data);
+ expect(json).not.toBe(struct[kData]);
+ expect(struct[kData]).toEqual(json);
+ });
+
+ test("XPropertyName variable matches the actual property's names", () => {
+ expect(Structure[DataTemplatePropertyName]).toStrictEqual({});
+ expect(struct[OptimizeDataPropertyName]).toBeTypeOf('function');
+ });
+});
diff --git a/packages/structures/__tests__/channels.test.ts b/packages/structures/__tests__/channels.test.ts
new file mode 100644
index 000000000..647f796ea
--- /dev/null
+++ b/packages/structures/__tests__/channels.test.ts
@@ -0,0 +1,741 @@
+import type {
+ APIAnnouncementThreadChannel,
+ APIDMChannel,
+ APIGroupDMChannel,
+ APIGuildCategoryChannel,
+ APIGuildForumChannel,
+ APIGuildMediaChannel,
+ APIGuildStageVoiceChannel,
+ APIGuildVoiceChannel,
+ APINewsChannel,
+ APIPrivateThreadChannel,
+ APIPublicThreadChannel,
+ APITextChannel,
+} from 'discord-api-types/v10';
+import {
+ ForumLayoutType,
+ SortOrderType,
+ ChannelType,
+ OverwriteType,
+ ThreadAutoArchiveDuration,
+ VideoQualityMode,
+ ChannelFlags,
+} from 'discord-api-types/v10';
+import { describe, expect, test } from 'vitest';
+import {
+ AnnouncementChannel,
+ AnnouncementThreadChannel,
+ CategoryChannel,
+ DMChannel,
+ ForumChannel,
+ ForumTag,
+ GroupDMChannel,
+ MediaChannel,
+ PermissionOverwrite,
+ PrivateThreadChannel,
+ PublicThreadChannel,
+ StageChannel,
+ TextChannel,
+ ThreadMetadata,
+ VoiceChannel,
+} from '../src/index.js';
+import { kData } from '../src/utils/symbols.js';
+
+describe('text channel', () => {
+ const data: APITextChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildText,
+ position: 0,
+ guild_id: '2',
+ last_message_id: '3',
+ last_pin_timestamp: '2020-10-10T13:50:17.209Z',
+ nsfw: true,
+ parent_id: '4',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ rate_limit_per_user: 9,
+ topic: 'hello',
+ default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
+ default_thread_rate_limit_per_user: 30,
+ };
+
+ test('TextChannel has all properties', () => {
+ const instance = new TextChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
+ expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance.lastPinTimestamp).toBe(Date.parse(data.last_pin_timestamp!));
+ expect(instance.lastPinAt?.toISOString()).toBe(data.last_pin_timestamp);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
+ expect(instance.topic).toBe(data.topic);
+ expect(instance.type).toBe(ChannelType.GuildText);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new TextChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+
+ test('PermissionOverwrite sub-structure', () => {
+ const instances = data.permission_overwrites?.map((overwrite) => new PermissionOverwrite(overwrite));
+ expect(instances?.map((overwrite) => overwrite.toJSON())).toEqual(data.permission_overwrites);
+ expect(instances?.[0]?.allow?.toJSON()).toBe(data.permission_overwrites?.[0]?.allow);
+ expect(instances?.[0]?.deny?.toJSON()).toBe(data.permission_overwrites?.[0]?.deny);
+ expect(instances?.[0]?.id).toBe(data.permission_overwrites?.[0]?.id);
+ expect(instances?.[0]?.type).toBe(data.permission_overwrites?.[0]?.type);
+ });
+});
+
+describe('announcement channel', () => {
+ const data: APINewsChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildAnnouncement,
+ position: 0,
+ guild_id: '2',
+ last_message_id: '3',
+ last_pin_timestamp: null,
+ nsfw: true,
+ parent_id: '4',
+ rate_limit_per_user: 9,
+ topic: 'hello',
+ default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
+ default_thread_rate_limit_per_user: 30,
+ };
+
+ test('AnnouncementChannel has all properties', () => {
+ const instance = new AnnouncementChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
+ expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance.lastPinTimestamp).toBe(null);
+ expect(instance.lastPinAt).toBe(data.last_pin_timestamp);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
+ expect(instance.topic).toBe(data.topic);
+ expect(instance.type).toBe(ChannelType.GuildAnnouncement);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new AnnouncementChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+});
+
+describe('category channel', () => {
+ const data: APIGuildCategoryChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildCategory,
+ position: 0,
+ guild_id: '2',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ };
+
+ test('CategoryChannel has all properties', () => {
+ const instance = new CategoryChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.type).toBe(ChannelType.GuildCategory);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new CategoryChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(false);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+});
+
+describe('DM channel', () => {
+ const dataNoRecipients: APIDMChannel = {
+ id: '1',
+ type: ChannelType.DM,
+ last_message_id: '3',
+ last_pin_timestamp: '2020-10-10T13:50:17.209Z',
+ name: null,
+ };
+
+ const data = {
+ ...dataNoRecipients,
+ recipients: [
+ {
+ avatar: '123',
+ discriminator: '0',
+ global_name: 'tester',
+ id: '1',
+ username: 'test',
+ },
+ ],
+ };
+
+ test('DMChannel has all properties', () => {
+ const instance = new DMChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance.lastPinTimestamp).toBe(Date.parse(data.last_pin_timestamp!));
+ expect(instance.lastPinAt?.toISOString()).toBe(data.last_pin_timestamp);
+ expect(instance[kData].recipients).toEqual(data.recipients);
+ expect(instance.type).toBe(ChannelType.DM);
+ expect(instance.url).toBe('https://discord.com/channels/@me/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('DMChannel with no recipients', () => {
+ const instance = new DMChannel(dataNoRecipients);
+ expect(instance[kData].recipients).toEqual(dataNoRecipients.recipients);
+ expect(instance.toJSON()).toEqual(dataNoRecipients);
+ });
+
+ test('type guards', () => {
+ const instance = new DMChannel(data);
+ expect(instance.isDMBased()).toBe(true);
+ expect(instance.isGuildBased()).toBe(false);
+ expect(instance.isPermissionCapable()).toBe(false);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+});
+
+describe('GroupDM channel', () => {
+ const data: APIGroupDMChannel = {
+ id: '1',
+ type: ChannelType.GroupDM,
+ last_message_id: '3',
+ name: 'name',
+ recipients: [
+ {
+ avatar: '123',
+ discriminator: '0',
+ global_name: 'tester',
+ id: '1',
+ username: 'test',
+ },
+ ],
+ last_pin_timestamp: null,
+ application_id: '34',
+ icon: 'abc',
+ managed: true,
+ owner_id: '567',
+ };
+
+ test('GroupDMChannel has all properties', () => {
+ const instance = new GroupDMChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance[kData].recipients).toEqual(data.recipients);
+ expect(instance.applicationId).toBe(data.application_id);
+ expect(instance.managed).toBe(data.managed);
+ expect(instance.ownerId).toBe(data.owner_id);
+ expect(instance.type).toBe(ChannelType.GroupDM);
+ expect(instance.icon).toBe(data.icon);
+ expect(instance.url).toBe('https://discord.com/channels/@me/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new GroupDMChannel(data);
+ expect(instance.isDMBased()).toBe(true);
+ expect(instance.isGuildBased()).toBe(false);
+ expect(instance.isPermissionCapable()).toBe(false);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+});
+
+describe('forum channel', () => {
+ const dataNoTags: Omit = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildForum,
+ position: 0,
+ guild_id: '2',
+ nsfw: true,
+ parent_id: '4',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ topic: 'hello',
+ default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
+ default_thread_rate_limit_per_user: 30,
+ default_forum_layout: ForumLayoutType.GalleryView,
+ default_reaction_emoji: {
+ emoji_id: '159',
+ emoji_name: null,
+ },
+ default_sort_order: SortOrderType.LatestActivity,
+ };
+ const data: APIGuildForumChannel = {
+ ...dataNoTags,
+ available_tags: [
+ {
+ name: 'emoji',
+ emoji_name: '😀',
+ moderated: false,
+ id: '789',
+ emoji_id: null,
+ },
+ ],
+ };
+
+ test('ForumChannel has all properties', () => {
+ const instance = new ForumChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
+ expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.defaultForumLayout).toBe(data.default_forum_layout);
+ expect(instance.defaultReactionEmoji).toBe(data.default_reaction_emoji);
+ expect(instance.defaultSortOrder).toBe(data.default_sort_order);
+ expect(instance[kData].available_tags).toEqual(data.available_tags);
+ expect(instance.topic).toBe(data.topic);
+ expect(instance.type).toBe(ChannelType.GuildForum);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new ForumChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(false);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(true);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+
+ test('ForumTag has all properties', () => {
+ const instances = data.available_tags.map((tag) => new ForumTag(tag));
+ expect(instances.map((tag) => tag.toJSON())).toEqual(data.available_tags);
+ expect(instances[0]?.id).toBe(data.available_tags[0]?.id);
+ expect(instances[0]?.emojiId).toBe(data.available_tags[0]?.emoji_id);
+ expect(instances[0]?.emojiName).toBe(data.available_tags[0]?.emoji_name);
+ expect(instances[0]?.name).toBe(data.available_tags[0]?.name);
+ expect(instances[0]?.moderated).toBe(data.available_tags[0]?.moderated);
+ expect(instances[0]?.emoji).toBe(data.available_tags[0]?.emoji_name);
+ });
+
+ test('omitted property from ForumChannel', () => {
+ const instance = new ForumChannel(dataNoTags);
+ expect(instance.toJSON()).toEqual(dataNoTags);
+ });
+});
+
+describe('media channel', () => {
+ const data: APIGuildMediaChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildMedia,
+ position: 0,
+ guild_id: '2',
+ nsfw: true,
+ parent_id: '4',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ topic: 'hello',
+ default_auto_archive_duration: ThreadAutoArchiveDuration.OneHour,
+ default_thread_rate_limit_per_user: 30,
+ available_tags: [
+ {
+ name: 'emoji',
+ emoji_name: null,
+ moderated: false,
+ id: '789',
+ emoji_id: '444',
+ },
+ ],
+ default_reaction_emoji: {
+ emoji_id: '159',
+ emoji_name: null,
+ },
+ default_sort_order: SortOrderType.LatestActivity,
+ };
+
+ test('MediaChannel has all properties', () => {
+ const instance = new MediaChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.defaultAutoArchiveDuration).toBe(data.default_auto_archive_duration);
+ expect(instance.defaultThreadRateLimitPerUser).toBe(data.default_thread_rate_limit_per_user);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance[kData].available_tags).toEqual(data.available_tags);
+ expect(instance.topic).toBe(data.topic);
+ expect(instance.type).toBe(ChannelType.GuildMedia);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new MediaChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(false);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(true);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+
+ test('ForumTag has all properties', () => {
+ const instances = data.available_tags.map((tag) => new ForumTag(tag));
+ expect(instances.map((tag) => tag.toJSON())).toEqual(data.available_tags);
+ expect(instances[0]?.emoji).toBe(`<:_:${data.available_tags[0]?.emoji_id}>`);
+ });
+});
+
+describe('voice channel', () => {
+ const data: APIGuildVoiceChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildVoice,
+ position: 0,
+ guild_id: '2',
+ last_message_id: '3',
+ nsfw: true,
+ parent_id: '4',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ rate_limit_per_user: 9,
+ bitrate: 7,
+ rtc_region: 'somewhere',
+ user_limit: 100,
+ video_quality_mode: VideoQualityMode.Full,
+ };
+
+ test('VoiceChannel has all properties', () => {
+ const instance = new VoiceChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.bitrate).toBe(data.bitrate);
+ expect(instance.rtcRegion).toBe(data.rtc_region);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance.videoQualityMode).toBe(data.video_quality_mode);
+ expect(instance.userLimit).toBe(data.user_limit);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
+ expect(instance.type).toBe(ChannelType.GuildVoice);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new VoiceChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(true);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+});
+
+describe('stage channel', () => {
+ const data: APIGuildStageVoiceChannel = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.GuildStageVoice,
+ position: 0,
+ guild_id: '2',
+ last_message_id: '3',
+ nsfw: true,
+ parent_id: '4',
+ permission_overwrites: [
+ {
+ allow: '123',
+ deny: '456',
+ type: OverwriteType.Member,
+ id: '5',
+ },
+ ],
+ rate_limit_per_user: 9,
+ bitrate: 7,
+ rtc_region: 'somewhere',
+ user_limit: 100,
+ video_quality_mode: VideoQualityMode.Full,
+ };
+
+ test('StageChannel has all properties', () => {
+ const instance = new StageChannel(data);
+ expect(instance.id).toBe(data.id);
+ expect(instance.name).toBe(data.name);
+ expect(instance.position).toBe(data.position);
+ expect(instance.bitrate).toBe(data.bitrate);
+ expect(instance.rtcRegion).toBe(data.rtc_region);
+ expect(instance.flags?.toJSON()).toBe(data.flags);
+ expect(instance.guildId).toBe(data.guild_id);
+ expect(instance.lastMessageId).toBe(data.last_message_id);
+ expect(instance.videoQualityMode).toBe(data.video_quality_mode);
+ expect(instance.nsfw).toBe(data.nsfw);
+ expect(instance.parentId).toBe(data.parent_id);
+ expect(instance[kData].permission_overwrites).toEqual(data.permission_overwrites);
+ expect(instance.rateLimitPerUser).toBe(data.rate_limit_per_user);
+ expect(instance.type).toBe(ChannelType.GuildStageVoice);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(data);
+ });
+
+ test('type guards', () => {
+ const instance = new StageChannel(data);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(true);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(false);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(true);
+ expect(instance.isWebhookCapable()).toBe(true);
+ });
+});
+
+describe('thread channels', () => {
+ const dataNoTags: Omit = {
+ id: '1',
+ name: 'test',
+ type: ChannelType.PublicThread,
+ guild_id: '2',
+ last_message_id: '3',
+ last_pin_timestamp: null,
+ nsfw: true,
+ parent_id: '4',
+ rate_limit_per_user: 9,
+ };
+
+ const dataPublic: APIPublicThreadChannel = {
+ ...dataNoTags,
+ applied_tags: ['567'],
+ };
+
+ const dataAnnounce: APIAnnouncementThreadChannel = {
+ ...dataPublic,
+ thread_metadata: {
+ archive_timestamp: '2024-09-08T12:01:02.345Z',
+ archived: false,
+ auto_archive_duration: ThreadAutoArchiveDuration.ThreeDays,
+ locked: true,
+ },
+ flags: ChannelFlags.Pinned,
+ type: ChannelType.AnnouncementThread,
+ };
+
+ const dataPrivate: APIPrivateThreadChannel = {
+ ...dataPublic,
+ thread_metadata: {
+ ...dataAnnounce.thread_metadata!,
+ create_timestamp: '2023-01-02T15:13:11.987Z',
+ invitable: true,
+ },
+ type: ChannelType.PrivateThread,
+ };
+
+ test('PublicThreadChannel has all properties', () => {
+ const instance = new PublicThreadChannel(dataPublic);
+ expect(instance.id).toBe(dataPublic.id);
+ expect(instance.name).toBe(dataPublic.name);
+ expect(instance.flags?.toJSON()).toBe(dataPublic.flags);
+ expect(instance.guildId).toBe(dataPublic.guild_id);
+ expect(instance.lastMessageId).toBe(dataPublic.last_message_id);
+ expect(instance.nsfw).toBe(dataPublic.nsfw);
+ expect(instance.parentId).toBe(dataPublic.parent_id);
+ expect(instance.rateLimitPerUser).toBe(dataPublic.rate_limit_per_user);
+ expect(instance.type).toBe(ChannelType.PublicThread);
+ expect(instance.appliedTags).toEqual(dataPublic.applied_tags);
+ expect(instance.memberCount).toBe(dataPublic.member_count);
+ expect(instance.messageCount).toBe(dataPublic.message_count);
+ expect(instance.totalMessageSent).toBe(dataPublic.total_message_sent);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(dataPublic);
+ });
+
+ test('type guards PublicThread', () => {
+ const instance = new PublicThreadChannel(dataPublic);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(false);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(true);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+
+ test('PrivateThreadChannel has all properties', () => {
+ const instance = new PrivateThreadChannel(dataPrivate);
+ expect(instance.id).toBe(dataPrivate.id);
+ expect(instance.name).toBe(dataPrivate.name);
+ expect(instance.flags?.toJSON()).toBe(dataPrivate.flags);
+ expect(instance.guildId).toBe(dataPrivate.guild_id);
+ expect(instance.lastMessageId).toBe(dataPrivate.last_message_id);
+ expect(instance.nsfw).toBe(dataPrivate.nsfw);
+ expect(instance.parentId).toBe(dataPrivate.parent_id);
+ expect(instance.rateLimitPerUser).toBe(dataPrivate.rate_limit_per_user);
+ expect(instance[kData].thread_metadata).toEqual(dataPrivate.thread_metadata);
+ expect(instance.type).toBe(ChannelType.PrivateThread);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(dataPrivate);
+ });
+
+ test('type guards PrivateThread', () => {
+ const instance = new PrivateThreadChannel(dataPrivate);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(false);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(true);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+
+ test('AnnouncementThreadChannel has all properties', () => {
+ const instance = new AnnouncementThreadChannel(dataAnnounce);
+ expect(instance.id).toBe(dataAnnounce.id);
+ expect(instance.name).toBe(dataAnnounce.name);
+ expect(instance.flags?.toJSON()).toBe(dataAnnounce.flags);
+ expect(instance.guildId).toBe(dataAnnounce.guild_id);
+ expect(instance.lastMessageId).toBe(dataAnnounce.last_message_id);
+ expect(instance.nsfw).toBe(dataAnnounce.nsfw);
+ expect(instance.parentId).toBe(dataAnnounce.parent_id);
+ expect(instance.rateLimitPerUser).toBe(dataAnnounce.rate_limit_per_user);
+ expect(instance[kData].thread_metadata).toEqual(dataAnnounce.thread_metadata);
+ expect(instance.type).toBe(ChannelType.AnnouncementThread);
+ expect(instance.url).toBe('https://discord.com/channels/2/1');
+ expect(instance.toJSON()).toEqual(dataAnnounce);
+ });
+
+ test('type guards AnnouncementThread', () => {
+ const instance = new AnnouncementThreadChannel(dataAnnounce);
+ expect(instance.isDMBased()).toBe(false);
+ expect(instance.isGuildBased()).toBe(true);
+ expect(instance.isPermissionCapable()).toBe(false);
+ expect(instance.isTextBased()).toBe(true);
+ expect(instance.isThread()).toBe(true);
+ expect(instance.isThreadOnly()).toBe(false);
+ expect(instance.isVoiceBased()).toBe(false);
+ expect(instance.isWebhookCapable()).toBe(false);
+ });
+
+ test('omitted property from PublicThread', () => {
+ const instance = new PublicThreadChannel(dataNoTags);
+ expect(instance.toJSON()).toEqual(dataNoTags);
+ expect(instance.appliedTags).toBe(null);
+ });
+
+ test('ThreadMetadata has all properties', () => {
+ const instance = new ThreadMetadata(dataPrivate.thread_metadata!);
+ expect(instance.toJSON()).toEqual(dataPrivate.thread_metadata);
+ expect(instance.archived).toBe(dataPrivate.thread_metadata?.archived);
+ expect(instance.archivedAt?.toISOString()).toBe(dataPrivate.thread_metadata?.archive_timestamp);
+ expect(instance.archivedTimestamp).toBe(Date.parse(dataPrivate.thread_metadata!.archive_timestamp));
+ expect(instance.createdAt?.toISOString()).toBe(dataPrivate.thread_metadata?.create_timestamp);
+ expect(instance.createdTimestamp).toBe(Date.parse(dataPrivate.thread_metadata!.create_timestamp!));
+ expect(instance.autoArchiveDuration).toBe(dataPrivate.thread_metadata?.auto_archive_duration);
+ expect(instance.invitable).toBe(dataPrivate.thread_metadata?.invitable);
+ expect(instance.locked).toBe(dataPrivate.thread_metadata?.locked);
+ });
+});
diff --git a/packages/structures/__tests__/invite.test.ts b/packages/structures/__tests__/invite.test.ts
new file mode 100644
index 000000000..256c3aa85
--- /dev/null
+++ b/packages/structures/__tests__/invite.test.ts
@@ -0,0 +1,90 @@
+import type { APIExtendedInvite, APIInvite } from 'discord-api-types/v10';
+import { InviteTargetType, InviteType } from 'discord-api-types/v10';
+import { describe, expect, test } from 'vitest';
+import { Invite } from '../src/index.js';
+import { kPatch } from '../src/utils/symbols.js';
+
+describe('Invite', () => {
+ const dataNoCode: Omit = {
+ type: InviteType.Guild,
+ channel: null,
+ approximate_member_count: 15,
+ approximate_presence_count: 35,
+ target_type: InviteTargetType.EmbeddedApplication,
+ };
+
+ const data: APIInvite = {
+ ...dataNoCode,
+ code: '123',
+ };
+
+ const dataExtended: APIExtendedInvite = {
+ ...data,
+ created_at: '2020-10-10T13:50:17.209Z',
+ max_age: 12,
+ max_uses: 34,
+ temporary: false,
+ uses: 5,
+ };
+
+ test('Invite has all properties', () => {
+ const instance = new Invite(data);
+ expect(instance.type).toBe(data.type);
+ expect(instance.code).toBe(data.code);
+ expect(instance.createdAt).toBe(null);
+ expect(instance.createdTimestamp).toBe(null);
+ expect(instance.maxAge).toBe(undefined);
+ expect(instance.maxUses).toBe(undefined);
+ expect(instance.approximateMemberCount).toBe(data.approximate_member_count);
+ expect(instance.approximatePresenceCount).toBe(data.approximate_presence_count);
+ expect(instance.targetType).toBe(data.target_type);
+ expect(instance.temporary).toBe(undefined);
+ expect(instance.uses).toBe(undefined);
+ expect(instance.expiresTimestamp).toBe(null);
+ expect(instance.expiresAt).toBe(null);
+ expect(instance.url).toBe('https://discord.gg/123');
+ expect(instance.toJSON()).toEqual(data);
+ expect(`${instance}`).toBe('https://discord.gg/123');
+ expect(instance.valueOf()).toBe(data.code);
+ });
+
+ test('extended Invite has all properties', () => {
+ const instance = new Invite(dataExtended);
+ expect(instance.type).toBe(data.type);
+ expect(instance.code).toBe(dataExtended.code);
+ expect(instance.createdAt?.toISOString()).toBe(dataExtended.created_at);
+ expect(instance.createdTimestamp).toBe(Date.parse(dataExtended.created_at));
+ expect(instance.maxAge).toBe(dataExtended.max_age);
+ expect(instance.maxUses).toBe(dataExtended.max_uses);
+ expect(instance.approximateMemberCount).toBe(dataExtended.approximate_member_count);
+ expect(instance.approximatePresenceCount).toBe(dataExtended.approximate_presence_count);
+ expect(instance.targetType).toBe(dataExtended.target_type);
+ expect(instance.temporary).toBe(dataExtended.temporary);
+ expect(instance.uses).toBe(dataExtended.uses);
+ expect(instance.expiresTimestamp).toStrictEqual(Date.parse('2020-10-10T13:50:29.209Z'));
+ expect(instance.expiresAt).toStrictEqual(new Date('2020-10-10T13:50:29.209Z'));
+ expect(instance.url).toBe('https://discord.gg/123');
+ expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
+ });
+
+ test('Invite with omitted properties', () => {
+ const instance = new Invite(dataNoCode);
+ expect(instance.toJSON()).toEqual(dataNoCode);
+ expect(instance.url).toBe(null);
+ expect(instance.code).toBe(undefined);
+ expect(`${instance}`).toBe('');
+ expect(instance.valueOf()).toEqual(Object.prototype.valueOf.apply(instance));
+ });
+
+ test('Invite with expiration', () => {
+ const instance = new Invite({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
+ expect(instance.toJSON()).toEqual({ ...dataExtended, expires_at: '2020-10-10T13:50:29.209Z' });
+ });
+
+ test('Patching Invite works in place', () => {
+ const instance1 = new Invite(data);
+ const instance2 = instance1[kPatch]({ max_age: 34 });
+ expect(instance1.toJSON()).not.toEqual(data);
+ expect(instance2).toBe(instance1);
+ });
+});
diff --git a/packages/structures/__tests__/mixinClasses.ts b/packages/structures/__tests__/mixinClasses.ts
new file mode 100644
index 000000000..4ef6acb3e
--- /dev/null
+++ b/packages/structures/__tests__/mixinClasses.ts
@@ -0,0 +1,132 @@
+import { Mixin } from '../src/Mixin.js';
+import type { MixinTypes } from '../src/MixinTypes.d.ts';
+import { Structure } from '../src/Structure.js';
+import { kData, kMixinConstruct, kMixinToJSON, kPatch } from '../src/utils/symbols.js';
+
+export interface APIData {
+ baseOptimize?: string;
+ id: string;
+ mixinOptimize?: string;
+ property1?: number;
+ property2?: boolean;
+}
+
+export class Base extends Structure {
+ public static override readonly DataTemplate = {
+ set baseOptimize(_: unknown) {},
+ };
+
+ public baseOptimize: boolean | null = null;
+
+ public constructor(data: APIData) {
+ super(data);
+ this.optimizeData(data);
+ }
+
+ public override [kPatch](data: Partial) {
+ super[kPatch](data);
+ return this;
+ }
+
+ public override optimizeData(data: Partial) {
+ if ('baseOptimize' in data) {
+ this.baseOptimize = Boolean(data.baseOptimize);
+ }
+ }
+
+ public get id() {
+ return this[kData].id;
+ }
+
+ public getId() {
+ return this.id;
+ }
+
+ public override toJSON() {
+ const data = super.toJSON();
+ if (this.baseOptimize) {
+ data.baseOptimize = String(this.baseOptimize);
+ }
+
+ return data;
+ }
+}
+
+export interface MixinProperty1 extends Base {
+ mixinOptimize: boolean | null;
+}
+export class MixinProperty1 {
+ public static readonly DataTemplate = {
+ set mixinOptimize(_: unknown) {},
+ };
+
+ public [kMixinConstruct]() {
+ this.mixinOptimize = null;
+ }
+
+ public optimizeData(data: Partial) {
+ if ('mixinOptimize' in data) {
+ this.mixinOptimize = Boolean(data.mixinOptimize);
+ }
+ }
+
+ public get property1() {
+ return this[kData].property1;
+ }
+
+ public getProperty1() {
+ return this.property1;
+ }
+
+ protected [kMixinToJSON](data: Partial) {
+ if (this.mixinOptimize) {
+ data.mixinOptimize = String(this.mixinOptimize);
+ }
+ }
+}
+
+export interface MixinProperty2 extends Base {
+ constructCalled: boolean;
+}
+export class MixinProperty2 {
+ public [kMixinConstruct]() {
+ this.constructCalled = true;
+ }
+
+ public get property2() {
+ return this[kData].property2;
+ }
+
+ public getProperty2() {
+ return this.property2;
+ }
+}
+
+export class ExtendedMixinProperty2 extends MixinProperty2 {
+ // eslint-disable-next-line @typescript-eslint/class-literal-property-style
+ public get isExtended() {
+ return true;
+ }
+}
+
+export interface Mixed extends MixinTypes {}
+export class Mixed extends Base {
+ public getProperties() {
+ return { property1: this.property1, property2: this.property2 };
+ }
+}
+
+Mixin(Mixed, [MixinProperty1, MixinProperty2]);
+
+export interface MixedWithExtended extends MixinTypes {}
+export class MixedWithExtended extends Base {
+ public getProperties() {
+ return {
+ property1: this.property1,
+ property2: this.property2,
+ };
+ }
+}
+
+// Intentionally don't directly mix Property 2
+Mixin(MixedWithExtended, [MixinProperty1, ExtendedMixinProperty2]);
diff --git a/packages/structures/__tests__/types/Mixin.test-d.ts b/packages/structures/__tests__/types/Mixin.test-d.ts
new file mode 100644
index 000000000..59ef4878f
--- /dev/null
+++ b/packages/structures/__tests__/types/Mixin.test-d.ts
@@ -0,0 +1,40 @@
+import { expectNotType, expectType } from 'tsd';
+import { expectTypeOf } from 'vitest';
+import type { MixinTypes } from '../../src/MixinTypes.d.ts';
+import type { kMixinConstruct } from '../../src/utils/symbols.js';
+import type { MixinProperty1, Base, MixinProperty2 } from '../mixinClasses.js';
+
+declare const extendsNoOmit: Omit;
+declare const extendsOmitProperty1: Omit, keyof Base | typeof kMixinConstruct>;
+declare const extendsBothNoOmit: Omit;
+declare const extendsBothOmitProperty1: Omit<
+ MixinProperty1<'property1'> & MixinProperty2<'property1'>,
+ keyof Base | typeof kMixinConstruct
+>;
+declare const extendsBothOmitBoth: Omit<
+ MixinProperty1<'property1'> & MixinProperty2<'property2'>,
+ keyof Base | typeof kMixinConstruct
+>;
+
+expectType>(extendsNoOmit);
+expectType, [MixinProperty1<'property1'>]>>(extendsOmitProperty1);
+expectNotType>(extendsOmitProperty1);
+expectNotType, [MixinProperty1<'property1'>]>>(extendsNoOmit);
+
+expectType>(extendsBothNoOmit);
+// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok
+expectType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothOmitProperty1);
+expectNotType>(extendsBothOmitProperty1);
+// Since MixinProperty2 doesn't utilize the type of property1 in kData, this works and is ok
+expectNotType, [MixinProperty1<'property1'>, MixinProperty2]>>(extendsBothNoOmit);
+
+// Earlier mixins in the list must specify all properties because of the way merging works
+expectType<
+ MixinTypes, [MixinProperty1<'property1' | 'property2'>, MixinProperty2<'property2'>]>
+>(extendsBothOmitBoth);
+
+expectTypeOf, [MixinProperty1]>>().toBeNever();
+// @ts-expect-error Shouldn't be able to assign non identical omits
+expectTypeOf]>>()
+ // Separate line so ts-expect-error doesn't match this ever
+ .toBeNever();
diff --git a/packages/structures/__tests__/types/channels.test-d.ts b/packages/structures/__tests__/types/channels.test-d.ts
new file mode 100644
index 000000000..bbb662ab3
--- /dev/null
+++ b/packages/structures/__tests__/types/channels.test-d.ts
@@ -0,0 +1,79 @@
+import type { ChannelType, GuildChannelType, GuildTextChannelType, ThreadChannelType } from 'discord-api-types/v10';
+import { expectNever, expectType } from 'tsd';
+import type { Channel } from '../../src/index.js';
+
+declare const channel: Channel;
+
+if (channel.isGuildBased()) {
+ expectType(channel.guildId);
+ expectType(channel.type);
+
+ if (channel.isDMBased()) {
+ expectNever(channel);
+ }
+
+ if (channel.isPermissionCapable()) {
+ expectType>(channel.type);
+ }
+
+ if (channel.isTextBased()) {
+ expectType(channel.type);
+ }
+
+ if (channel.isWebhookCapable()) {
+ expectType>(
+ channel.type,
+ );
+ }
+
+ if (channel.isThread()) {
+ expectType(channel.type);
+ }
+
+ if (channel.isThreadOnly()) {
+ expectType(channel.type);
+ }
+
+ if (channel.isVoiceBased()) {
+ expectType(channel.type);
+ if (!channel.isTextBased()) {
+ expectNever(channel);
+ }
+
+ if (!channel.isWebhookCapable()) {
+ expectNever(channel);
+ }
+ }
+}
+
+if (channel.isDMBased()) {
+ expectType(channel.type);
+
+ if (channel.isGuildBased()) {
+ expectNever(channel);
+ }
+
+ if (channel.isPermissionCapable()) {
+ expectNever(channel);
+ }
+
+ if (channel.isWebhookCapable()) {
+ expectNever(channel);
+ }
+
+ if (channel.isVoiceBased()) {
+ expectNever(channel);
+ }
+
+ if (channel.isThread()) {
+ expectNever(channel);
+ }
+
+ if (channel.isThreadOnly()) {
+ expectNever(channel);
+ }
+
+ if (channel.isTextBased()) {
+ expectType(channel.type);
+ }
+}
diff --git a/packages/structures/api-extractor.json b/packages/structures/api-extractor.json
new file mode 100644
index 000000000..4feae888c
--- /dev/null
+++ b/packages/structures/api-extractor.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../api-extractor.json",
+ "docModel": {
+ "projectFolderUrl": "https://github.com/discordjs/discord.js/tree/main/packages/structures"
+ },
+ "compiler": {
+ "overrideTsconfig": {
+ "exclude": ["src/**/*.d.ts"]
+ }
+ }
+}
diff --git a/packages/structures/cliff.toml b/packages/structures/cliff.toml
new file mode 100644
index 000000000..211142607
--- /dev/null
+++ b/packages/structures/cliff.toml
@@ -0,0 +1,79 @@
+[changelog]
+header = """
+# Changelog
+
+All notable changes to this project will be documented in this file.\n
+"""
+body = """
+{%- macro remote_url() -%}
+ https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
+{%- endmacro -%}
+{% if version %}\
+ # [{{ version | trim_start_matches(pat="v") }}]\
+ {% if previous %}\
+ {% if previous.version %}\
+ ({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})\
+ {% else %}\
+ ({{ self::remote_url() }}/tree/{{ version }})\
+ {% endif %}\
+ {% endif %} \
+ - ({{ timestamp | date(format="%Y-%m-%d") }})
+{% else %}\
+ # [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ## {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.scope %}\
+ **{{commit.scope}}:** \
+ {% endif %}\
+ {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\
+ {% if commit.github.username %} by @{{ commit.github.username }}{%- endif %}\
+ {% if commit.breaking %}\
+ {% for footer in commit.footers %}\
+ {% if footer.breaking %}\
+ \n{% raw %} {% endraw %}- **{{ footer.token }}{{ footer.separator }}** {{ footer.value }}\
+ {% endif %}\
+ {% endfor %}\
+ {% endif %}\
+ {% endfor %}
+{% endfor %}\
+{% if github.contributors | filter(attribute="is_first_time", value=true) | length %}\
+ \n### New Contributors\n
+ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}\
+ * @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }}
+ {% endfor %}\
+{% endif %}\n
+"""
+trim = true
+footer = ""
+
+[git]
+conventional_commits = true
+filter_unconventional = true
+commit_parsers = [
+ { message = "^feat", group = "Features"},
+ { message = "^fix", group = "Bug Fixes"},
+ { message = "^docs", group = "Documentation"},
+ { message = "^perf", group = "Performance"},
+ { message = "^refactor", group = "Refactor"},
+ { message = "^types", group = "Typings"},
+ { message = ".*deprecated", body = ".*deprecated", group = "Deprecation"},
+ { message = "^revert", skip = true},
+ { message = "^style", group = "Styling"},
+ { message = "^test", group = "Testing"},
+ { message = "^chore", skip = true},
+ { message = "^ci", skip = true},
+ { message = "^build", skip = true},
+ { body = ".*security", group = "Security"},
+]
+filter_commits = true
+protect_breaking_commits = true
+tag_pattern = "@discordjs/structures@[0-9]*"
+ignore_tags = ""
+topo_order = false
+sort_commits = "newest"
+
+[remote.github]
+owner = "discordjs"
+repo = "discord.js"
diff --git a/packages/structures/docs/README.md b/packages/structures/docs/README.md
new file mode 100644
index 000000000..6d683030d
--- /dev/null
+++ b/packages/structures/docs/README.md
@@ -0,0 +1 @@
+## [View the documentation here.](https://discord.js.org/docs/packages/structures/main)
diff --git a/packages/structures/package.json b/packages/structures/package.json
new file mode 100644
index 000000000..3b69b2d00
--- /dev/null
+++ b/packages/structures/package.json
@@ -0,0 +1,98 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@discordjs/structures",
+ "version": "0.1.0",
+ "description": "Wrapper around Discord's structures",
+ "scripts": {
+ "build": "tsc --noEmit && tsup",
+ "build:docs": "tsc -p tsconfig.docs.json && cpy \"./src/*.d.ts\" \"./dist-docs\"",
+ "test": "vitest run --config ../../vitest.config.ts",
+ "lint": "prettier --check . && cross-env TIMING=1 eslint --format=pretty src",
+ "format": "prettier --write . && cross-env TIMING=1 eslint --fix --format=pretty src",
+ "fmt": "pnpm run format",
+ "docs": "pnpm run build:docs && api-extractor run --local --minify && generate-split-documentation",
+ "prepack": "pnpm run build && pnpm run lint",
+ "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/structures/*'",
+ "release": "cliff-jumper"
+ },
+ "exports": {
+ ".": {
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ }
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "directories": {
+ "lib": "src",
+ "test": "__tests__"
+ },
+ "files": [
+ "dist"
+ ],
+ "contributors": [
+ "Crawl ",
+ "SpaceEEC ",
+ "Vlad Frangu ",
+ "Aura Román ",
+ "Chai Kohen "
+ ],
+ "license": "Apache-2.0",
+ "keywords": [
+ "discord",
+ "api",
+ "discordapp",
+ "discordjs"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/discordjs/discord.js.git",
+ "directory": "packages/structures"
+ },
+ "bugs": {
+ "url": "https://github.com/discordjs/discord.js/issues"
+ },
+ "homepage": "https://discord.js.org",
+ "dependencies": {
+ "@discordjs/formatters": "workspace:^",
+ "@sapphire/snowflake": "^3.5.5",
+ "discord-api-types": "^0.38.15"
+ },
+ "devDependencies": {
+ "@discordjs/api-extractor": "workspace:^",
+ "@discordjs/scripts": "workspace:^",
+ "@favware/cliff-jumper": "^4.1.0",
+ "@types/node": "^22.15.2",
+ "@vitest/coverage-v8": "^3.1.1",
+ "cpy-cli": "^5.0.0",
+ "cross-env": "^7.0.3",
+ "esbuild-plugin-version-injector": "^1.2.1",
+ "eslint": "^9.25.1",
+ "eslint-config-neon": "^0.2.7",
+ "eslint-formatter-compact": "^8.40.0",
+ "eslint-formatter-pretty": "^6.0.1",
+ "prettier": "^3.5.3",
+ "tsd": "^0.31.2",
+ "tsup": "^8.4.0",
+ "turbo": "^2.5.2",
+ "typescript": "~5.8.3",
+ "vitest": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "tsd": {
+ "directory": "__tests__/types"
+ }
+}
diff --git a/packages/structures/src/Mixin.ts b/packages/structures/src/Mixin.ts
new file mode 100644
index 000000000..8f637a5a9
--- /dev/null
+++ b/packages/structures/src/Mixin.ts
@@ -0,0 +1,179 @@
+import { DataTemplatePropertyName, OptimizeDataPropertyName, type Structure } from './Structure.js';
+import { kMixinConstruct, kMixinToJSON } from './utils/symbols.js';
+
+export type Mixinable = new (...args: unknown[]) => ClassType;
+
+export type MixinBase> =
+ BaseClass extends Structure ? Structure : never;
+
+/**
+ * Copies the prototype (getters, setters, and methods) of all mixins to the destination class.
+ * For type information see {@link MixinTypes}
+ *
+ * @param destination - The class to apply the mixins to, must extend the base that the mixins expect it to.
+ * @param mixins - Classes that contain "pure" prototypes to be copied on top of the destination class prototype
+ * @remarks All mixins should be "pure" in that they only contain getters, setters, and methods.
+ * The runtime code will only copy these, and adding properties to the class only results
+ * in the types of the mixed class being wrong.
+ * @example
+ * ```
+ * // Interface merging on the mixin to give type access to props on the base and kData that are available once copied
+ * interface TextMixin extends Channel {}
+ * class TextMixin {
+ * // Methods / getters
+ * }
+ *
+ * // Interface merging on the mixed class to give it accurate type information within the declaration and when instantiated
+ * interface TextChannel extends MixinTypes {}
+ * class TextChannel extends Channel {}
+ *
+ * // Apply for runtime
+ * Mixin(TextChannel, [TextMixin])
+ * ```
+ * @typeParam DestinationClass - The class to be mixed, ensures that the mixins provided can be used with this destination
+ */
+export function Mixin>(
+ destination: DestinationClass,
+ mixins: Mixinable>[],
+) {
+ const dataTemplates: Record[] = [];
+ const dataOptimizations: ((data: unknown) => void)[] = [];
+ const enrichToJSONs: ((data: Partial) => void)[] = [];
+ const constructors: ((data: Partial) => void)[] = [];
+
+ for (const mixin of mixins) {
+ // The entire prototype chain, in reverse order, since we want to copy it all
+ const prototypeChain: MixinBase[] = [];
+ let extendedClass = mixin;
+ while (extendedClass.prototype !== undefined) {
+ if (
+ DataTemplatePropertyName in extendedClass &&
+ typeof extendedClass.DataTemplate === 'object' &&
+ // eslint-disable-next-line no-eq-null, eqeqeq
+ extendedClass.DataTemplate != null
+ ) {
+ dataTemplates.push(extendedClass.DataTemplate as Record);
+ }
+
+ prototypeChain.unshift(extendedClass.prototype);
+ extendedClass = Object.getPrototypeOf(extendedClass);
+ }
+
+ for (const prototype of prototypeChain) {
+ // Symboled data isn't traversed by Object.entries, we can handle it here
+ if (prototype[kMixinConstruct]) {
+ constructors.push(prototype[kMixinConstruct]);
+ }
+
+ if (prototype[kMixinToJSON]) {
+ enrichToJSONs.push(prototype[kMixinToJSON]);
+ }
+
+ // Copy instance methods and setters / getters
+ const originalDescriptors = Object.getOwnPropertyDescriptors(prototype);
+ const usingDescriptors: { [prop: string]: PropertyDescriptor } = {};
+ for (const [prop, descriptor] of Object.entries(originalDescriptors)) {
+ // Drop constructor
+ if (['constructor'].includes(prop)) {
+ continue;
+ }
+
+ // Special case for optimize function, we want to combine these
+ if (prop === OptimizeDataPropertyName) {
+ if (typeof descriptor.value !== 'function')
+ throw new RangeError(`Expected ${prop} to be a function, received ${typeof descriptor.value} instead.`);
+ dataOptimizations.push(descriptor.value);
+ continue;
+ }
+
+ // Shouldn't be anything other than these without being instantiated, but just in case
+ if (
+ typeof descriptor.get !== 'undefined' ||
+ typeof descriptor.set !== 'undefined' ||
+ typeof descriptor.value === 'function'
+ ) {
+ usingDescriptors[prop] = descriptor;
+ }
+ }
+
+ Object.defineProperties(destination.prototype, usingDescriptors);
+ }
+ }
+
+ // Set the function to call any mixed constructors
+ if (constructors.length > 0) {
+ Object.defineProperty(destination.prototype, kMixinConstruct, {
+ writable: true,
+ enumerable: false,
+ configurable: true,
+ // eslint-disable-next-line func-name-matching
+ value: function _mixinConstructors(data: Partial) {
+ for (const construct of constructors) {
+ construct.call(this, data);
+ }
+ },
+ });
+ }
+
+ // Combine all optimizations into a single function
+ const baseOptimize = Object.getOwnPropertyDescriptor(destination, OptimizeDataPropertyName);
+ if (baseOptimize && typeof baseOptimize.value === 'function') {
+ // call base last (mimic constructor behavior)
+ dataOptimizations.push(baseOptimize.value);
+ }
+
+ const superOptimize = Object.getOwnPropertyDescriptor(
+ Object.getPrototypeOf(destination).prototype,
+ OptimizeDataPropertyName,
+ );
+ // the mixin base optimize should call super, so we can ignore the super in that case
+ if (!baseOptimize && superOptimize && typeof superOptimize.value === 'function') {
+ // call super first (mimic constructor behavior)
+ dataOptimizations.unshift(superOptimize.value);
+ }
+
+ // If there's more than one optimization or if there's an optimization that isn't on the destination (base)
+ if (dataOptimizations.length > 1 || (dataOptimizations.length === 1 && !baseOptimize)) {
+ Object.defineProperty(destination.prototype, OptimizeDataPropertyName, {
+ writable: true,
+ enumerable: false,
+ configurable: true,
+ // eslint-disable-next-line func-name-matching
+ value: function _mixinOptimizeData(data: unknown) {
+ for (const optimization of dataOptimizations) {
+ optimization.call(this, data);
+ }
+ },
+ });
+ }
+
+ if (enrichToJSONs.length > 0) {
+ Object.defineProperty(destination.prototype, kMixinToJSON, {
+ writable: true,
+ enumerable: false,
+ configurable: true,
+ // eslint-disable-next-line func-name-matching
+ value: function _mixinToJSON(data: Partial) {
+ for (const enricher of enrichToJSONs) {
+ enricher.call(this, data);
+ }
+ },
+ });
+ }
+
+ // Copy the properties (setters) of each mixins template to the destinations template
+ if (dataTemplates.length > 0) {
+ if (!Object.getOwnPropertyDescriptor(destination, DataTemplatePropertyName)) {
+ Object.defineProperty(destination, DataTemplatePropertyName, {
+ value: Object.defineProperties({}, Object.getOwnPropertyDescriptors(destination[DataTemplatePropertyName])),
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ });
+ }
+
+ for (const template of dataTemplates) {
+ Object.defineProperties(destination[DataTemplatePropertyName], Object.getOwnPropertyDescriptors(template));
+ }
+ }
+}
diff --git a/packages/structures/src/MixinTypes.d.ts b/packages/structures/src/MixinTypes.d.ts
new file mode 100644
index 000000000..94f38190c
--- /dev/null
+++ b/packages/structures/src/MixinTypes.d.ts
@@ -0,0 +1,23 @@
+import type { MixinBase } from './Mixin.js';
+import type { Structure } from './Structure.js';
+import type { kData, kMixinConstruct } from './utils/symbols.js';
+import type { CollapseUnion, MergePrototypes } from './utils/types.js';
+
+/**
+ * Type utility to provide accurate types for the runtime effects of {@link Mixin}
+ *
+ * @typeParam BaseClass - The class that is being directly extended, must match the class that the mixins are expecting
+ * @typeParam Mixins - The mixins that will be applied to this class via a {@link Mixin} call
+ */
+export type MixinTypes, Mixins extends readonly MixinBase[]> = CollapseUnion<
+ BaseClass extends Structure
+ ? Mixins[number] extends Structure
+ ? // prettier-ignore
+ Structure[typeof kData] extends
+ // @ts-expect-error kData is protected
+ Mixins[number][typeof kData]
+ ? Omit, keyof BaseClass | typeof kMixinConstruct>
+ : never
+ : never
+ : never
+>;
diff --git a/packages/structures/src/Structure.ts b/packages/structures/src/Structure.ts
new file mode 100644
index 000000000..9e817f87d
--- /dev/null
+++ b/packages/structures/src/Structure.ts
@@ -0,0 +1,144 @@
+import { kClone, kData, kMixinConstruct, kMixinToJSON, kPatch } from './utils/symbols.js';
+import type { ReplaceOmittedWithUnknown } from './utils/types.js';
+
+export const DataTemplatePropertyName = 'DataTemplate';
+export const OptimizeDataPropertyName = 'optimizeData';
+
+/**
+ * Represents a data model from the Discord API
+ *
+ * @privateRemarks
+ * Explanation of the type complexity surround Structure:
+ *
+ * There are two layers of Omitted generics, one here, which allows omitting things at the library level so we do not accidentally
+ * access them, in addition to whatever the user does at the layer above.
+ *
+ * The second layer, in the exported structure is effectively a type cast that allows the getters types to match whatever data template is used
+ *
+ * In order to safely set and access this data, the constructor and patch take data as "partial" and forcibly assigns it to kData. To accommodate this,
+ * kData stores properties as `unknown` when it is omitted, which allows accessing the property in getters even when it may not actually be present.
+ * This is the most technically correct way of representing the value, especially since there is no way to guarantee runtime matches the "type cast."
+ */
+export abstract class Structure {
+ /**
+ * A construct function used when mixing to allow mixins to set optimized property defaults
+ *
+ * @internal
+ * @remarks This should only be used to set defaults, setting optimized values should be done
+ * in the mixins `optimizeData` method, which will be called automatically.
+ * @param data - The full API data received by the Structure
+ */
+ protected [kMixinConstruct]?(data: Partial): void;
+
+ /**
+ * A function used when mixing to allow mixins to add properties to the result of toJSON
+ *
+ * @internal
+ * @remarks This should only be used to add properties that the mixin optimizes, if the raw
+ * JSON data is unchanged the property will already be returned.
+ * @param data - The result of the base class toJSON Structure before it gets returned
+ */
+ protected [kMixinToJSON]?(data: Partial): void;
+
+ /**
+ * The template used for removing data from the raw data stored for each Structure.
+ *
+ * @remarks This template should be overridden in all subclasses to provide more accurate type information.
+ * The template in the base {@link Structure} class will have no effect on most subclasses for this reason.
+ */
+ protected static readonly DataTemplate: Record = {};
+
+ /**
+ * @returns A cloned version of the data template, ready to create a new data object.
+ */
+ private getDataTemplate() {
+ return Object.create((this.constructor as typeof Structure).DataTemplate);
+ }
+
+ /**
+ * The raw data from the API for this structure
+ *
+ * @internal
+ */
+ protected [kData]: Readonly>;
+
+ /**
+ * Creates a new structure to represent API data
+ *
+ * @param data - the data from the API that this structure will represent
+ * @remarks To be made public in subclasses
+ * @internal
+ */
+ public constructor(data: Readonly>, ..._rest: unknown[]) {
+ this[kData] = Object.assign(this.getDataTemplate(), data);
+ this[kMixinConstruct]?.(data);
+ }
+
+ /**
+ * Patches the raw data of this object in place
+ *
+ * @param data - the updated data from the API to patch with
+ * @remarks To be made public in subclasses
+ * @returns this
+ * @internal
+ */
+ protected [kPatch](data: Readonly>): this {
+ this[kData] = Object.assign(this.getDataTemplate(), this[kData], data);
+ this.optimizeData(data);
+ return this;
+ }
+
+ /**
+ * Creates a clone of this structure
+ *
+ * @returns a clone of this
+ * @internal
+ */
+ protected [kClone](patchPayload?: Readonly>): typeof this {
+ const clone = this.toJSON();
+ // @ts-expect-error constructor is of abstract class is unknown
+ return new this.constructor(
+ // Ensure the ts-expect-error only applies to the constructor call
+ patchPayload ? Object.assign(clone, patchPayload) : clone,
+ );
+ }
+
+ /**
+ * Function called to ensure stored raw data is in optimized formats, used in tandem with a data template
+ *
+ * @example created_timestamp is an ISO string, this can be stored in optimized form as a number
+ * @param _data - the raw data received from the API to optimize
+ * @remarks Implementation to be done in subclasses and mixins where needed.
+ * For typescript users, mixins must use the closest ancestors access modifier.
+ * @remarks Automatically called in Structure[kPatch] but must be called manually in the constructor
+ * of any class implementing this method.
+ * @remarks Additionally, when implementing, ensure to call `super._optimizeData` if any class in the super chain aside
+ * from Structure contains an implementation.
+ * Note: mixins do not need to call super ever as the process of mixing walks the prototype chain.
+ * @virtual
+ * @internal
+ */
+ protected optimizeData(_data: Partial) {}
+
+ /**
+ * Transforms this object to its JSON format with raw API data (or close to it),
+ * automatically called by `JSON.stringify()` when this structure is stringified
+ *
+ * @remarks
+ * The type of this data is determined by omissions at runtime and is only guaranteed for default omissions
+ * @privateRemarks
+ * When omitting properties at the library level, this must be overridden to re-add those properties
+ */
+ public toJSON(): DataType {
+ // This will be DataType provided nothing is omitted, when omits occur, subclass needs to overwrite this.
+ const data =
+ // Spread is way faster than structuredClone, but is shallow. So use it only if there is no nested objects
+ (
+ Object.values(this[kData]).some((value) => typeof value === 'object' && value !== null)
+ ? structuredClone(this[kData])
+ : { ...this[kData] }
+ ) as DataType;
+ this[kMixinToJSON]?.(data);
+ return data;
+ }
+}
diff --git a/packages/structures/src/bitfields/BitField.ts b/packages/structures/src/bitfields/BitField.ts
new file mode 100644
index 000000000..e7e0138c4
--- /dev/null
+++ b/packages/structures/src/bitfields/BitField.ts
@@ -0,0 +1,203 @@
+import type { EnumLike, NonAbstract, RecursiveReadonlyArray } from '../utils/types.js';
+
+// TODO: this currently is mostly copied from mainlib discord.js v14 and definitely needs a refactor in a later iteration
+
+/**
+ * Data that can be resolved to give a bit field. This can be:
+ * A bit number (this can be a number literal or a value taken from {@link (BitField:class).Flags})
+ * A string bit number
+ * An instance of BitField
+ * An Array of BitFieldResolvable
+ */
+export type BitFieldResolvable =
+ | Flags
+ | Readonly>
+ | RecursiveReadonlyArray> | bigint | number | `${bigint}`>
+ | bigint
+ | number
+ | `${bigint}`;
+
+/**
+ * Data structure that makes it easy to interact with a bit field.
+ */
+export abstract class BitField {
+ /**
+ * Numeric bit field flags.
+ *
+ * @remarks Defined in extension classes
+ */
+ public static readonly Flags: EnumLike = {};
+
+ public static readonly DefaultBit: bigint = 0n;
+
+ /**
+ * Bitfield of the packed bits
+ */
+ public bitField: bigint;
+
+ declare public ['constructor']: NonAbstract>;
+
+ /**
+ * @param bits - Bit(s) to read from
+ */
+ public constructor(bits: BitFieldResolvable = this.constructor.DefaultBit) {
+ this.bitField = this.constructor.resolve(bits);
+ }
+
+ /**
+ * Checks whether the bit field has a bit, or any of multiple bits.
+ *
+ * @param bit - Bit(s) to check for
+ * @returns Whether the bit field has the bit(s)
+ */
+ public any(bit: BitFieldResolvable) {
+ return (this.bitField & this.constructor.resolve(bit)) !== this.constructor.DefaultBit;
+ }
+
+ /**
+ * Checks if this bit field equals another
+ *
+ * @param bit - Bit(s) to check for
+ * @returns Whether this bit field equals the other
+ */
+ public equals(bit: BitFieldResolvable) {
+ return this.bitField === this.constructor.resolve(bit);
+ }
+
+ /**
+ * Checks whether the bit field has a bit, or multiple bits.
+ *
+ * @param bit - Bit(s) to check for
+ * @returns Whether the bit field has the bit(s)
+ */
+ public has(bit: BitFieldResolvable, ..._hasParams: unknown[]) {
+ const resolvedBit = this.constructor.resolve(bit);
+ return (this.bitField & resolvedBit) === resolvedBit;
+ }
+
+ /**
+ * Gets all given bits that are missing from the bit field.
+ *
+ * @param bits - Bit(s) to check for
+ * @param hasParams - Additional parameters for the has method, if any
+ * @returns A bit field containing the missing bits
+ */
+ public missing(bits: BitFieldResolvable, ...hasParams: readonly unknown[]) {
+ return new this.constructor(bits).remove(this).toArray(...hasParams);
+ }
+
+ /**
+ * Freezes these bits, making them immutable.
+ *
+ * @returns This bit field but frozen
+ */
+ public freeze() {
+ return Object.freeze(this);
+ }
+
+ /**
+ * Adds bits to these ones.
+ *
+ * @param bits - Bits to add
+ * @returns These bits or new BitField if the instance is frozen.
+ */
+ public add(...bits: BitFieldResolvable[]) {
+ let total = this.constructor.DefaultBit;
+ for (const bit of bits) {
+ total |= this.constructor.resolve(bit);
+ }
+
+ if (Object.isFrozen(this)) return new this.constructor(this.bitField | total);
+ this.bitField |= total;
+ return this;
+ }
+
+ /**
+ * Removes bits from these.
+ *
+ * @param bits - Bits to remove
+ * @returns These bits or new BitField if the instance is frozen.
+ */
+ public remove(...bits: BitFieldResolvable[]) {
+ let total = this.constructor.DefaultBit;
+ for (const bit of bits) {
+ total |= this.constructor.resolve(bit);
+ }
+
+ if (Object.isFrozen(this)) return new this.constructor(this.bitField & ~total);
+ this.bitField &= ~total;
+ return this;
+ }
+
+ /**
+ * Gets an object mapping field names to a boolean indicating whether the bit is available.
+ *
+ * @param hasParams - Additional parameters for the has method, if any
+ * @returns An object mapping field names to a boolean indicating whether the bit is available
+ */
+ public serialize(...hasParams: readonly unknown[]) {
+ const serialized: Partial> = {};
+ for (const [flag, bit] of Object.entries(this.constructor.Flags)) {
+ if (Number.isNaN(Number(flag))) serialized[flag as keyof Flags] = this.has(bit as bigint | number, ...hasParams);
+ }
+
+ return serialized;
+ }
+
+ /**
+ * Gets an Array of bit field names based on the bits available.
+ *
+ * @param hasParams - Additional parameters for the has method, if any
+ * @returns An Array of bit field names
+ */
+ public toArray(...hasParams: readonly unknown[]) {
+ return [...this[Symbol.iterator](...hasParams)];
+ }
+
+ public toJSON(asNumber?: boolean) {
+ if (asNumber) {
+ if (this.bitField > Number.MAX_SAFE_INTEGER) {
+ throw new RangeError(
+ `Cannot convert bitfield value ${this.bitField} to number, as it is bigger than ${Number.MAX_SAFE_INTEGER} (the maximum safe integer)`,
+ );
+ }
+
+ return Number(this.bitField);
+ }
+
+ return this.bitField.toString();
+ }
+
+ public valueOf() {
+ return this.bitField;
+ }
+
+ public *[Symbol.iterator](...hasParams: unknown[]) {
+ for (const bitName of Object.keys(this.constructor.Flags)) {
+ if (Number.isNaN(Number(bitName)) && this.has(bitName as Flags, ...hasParams)) yield bitName as Flags;
+ }
+ }
+
+ /**
+ * Resolves bit fields to their numeric form.
+ *
+ * @param bit - bit(s) to resolve
+ * @returns the numeric value of the bit fields
+ */
+ public static resolve(bit: BitFieldResolvable): bigint {
+ const DefaultBit = this.DefaultBit;
+ if (typeof bit === 'bigint' && bit >= DefaultBit) return bit;
+ if (typeof bit === 'number' && BigInt(bit) >= DefaultBit) return BigInt(bit);
+ if (bit instanceof BitField) return bit.bitField;
+ if (Array.isArray(bit)) {
+ return bit.map((bit_) => this.resolve(bit_)).reduce((prev, bit_) => prev | bit_, DefaultBit);
+ }
+
+ if (typeof bit === 'string') {
+ if (!Number.isNaN(Number(bit))) return BigInt(bit);
+ if (bit in this.Flags) return this.Flags[bit as keyof typeof this.Flags];
+ }
+
+ throw new Error(`BitFieldInvalid: ${JSON.stringify(bit)}`);
+ }
+}
diff --git a/packages/structures/src/bitfields/ChannelFlagsBitField.ts b/packages/structures/src/bitfields/ChannelFlagsBitField.ts
new file mode 100644
index 000000000..309cf9615
--- /dev/null
+++ b/packages/structures/src/bitfields/ChannelFlagsBitField.ts
@@ -0,0 +1,16 @@
+import { ChannelFlags } from 'discord-api-types/v10';
+import { BitField } from './BitField.js';
+
+/**
+ * Data structure that makes it easy to interact with a {@link (Channel:class).flags} bitfield.
+ */
+export class ChannelFlagsBitField extends BitField {
+ /**
+ * Numeric guild channel flags.
+ */
+ public static override readonly Flags = ChannelFlags;
+
+ public override toJSON() {
+ return super.toJSON(true);
+ }
+}
diff --git a/packages/structures/src/bitfields/PermissionsBitField.ts b/packages/structures/src/bitfields/PermissionsBitField.ts
new file mode 100644
index 000000000..de38b4239
--- /dev/null
+++ b/packages/structures/src/bitfields/PermissionsBitField.ts
@@ -0,0 +1,76 @@
+/* eslint-disable unicorn/consistent-function-scoping */
+import { PermissionFlagsBits } from 'discord-api-types/v10';
+import type { BitFieldResolvable } from './BitField.js';
+import { BitField } from './BitField.js';
+
+/**
+ * Data structure that makes it easy to interact with a permission bit field. All {@link GuildMember}s have a set of
+ * permissions in their guild, and each channel in the guild may also have {@link PermissionOverwrite}s for the member
+ * that override their default permissions.
+ */
+export class PermissionsBitField extends BitField {
+ /**
+ * Numeric permission flags.
+ *
+ * @see {@link https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags}
+ */
+ public static override Flags = PermissionFlagsBits;
+
+ /**
+ * Bit field representing every permission combined
+ */
+ public static readonly All = Object.values(PermissionFlagsBits).reduce((all, perm) => all | perm, 0n);
+
+ /**
+ * Bit field representing the default permissions for users
+ */
+ public static readonly Default = 104_324_673n;
+
+ /**
+ * Bit field representing the permissions required for moderators of stage channels
+ */
+ public static readonly StageModerator =
+ PermissionFlagsBits.ManageChannels | PermissionFlagsBits.MuteMembers | PermissionFlagsBits.MoveMembers;
+
+ /**
+ * Gets all given bits that are missing from the bit field.
+ *
+ * @param bits - Bit(s) to check for
+ * @param checkAdmin - Whether to allow the administrator permission to override
+ * @returns A bit field containing the missing permissions
+ */
+ public override missing(bits: BitFieldResolvable, checkAdmin = true) {
+ return checkAdmin && this.has(PermissionFlagsBits.Administrator) ? [] : super.missing(bits);
+ }
+
+ /**
+ * Checks whether the bit field has a permission, or any of multiple permissions.
+ *
+ * @param permission - Permission(s) to check for
+ * @param checkAdmin - Whether to allow the administrator permission to override
+ * @returns Whether the bit field has the permission(s)
+ */
+ public override any(permission: BitFieldResolvable, checkAdmin = true) {
+ return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.any(permission);
+ }
+
+ /**
+ * Checks whether the bit field has a permission, or multiple permissions.
+ *
+ * @param permission - Permission(s) to check for
+ * @param checkAdmin - Whether to allow the administrator permission to override
+ * @returns Whether the bit field has the permission(s)
+ */
+ public override has(permission: BitFieldResolvable, checkAdmin = true) {
+ return (checkAdmin && super.has(PermissionFlagsBits.Administrator)) || super.has(permission);
+ }
+
+ /**
+ * Gets an Array of bitfield names based on the permissions available.
+ *
+ * @returns An Array of permission names
+ */
+ public override toArray() {
+ return super.toArray(false);
+ }
+}
diff --git a/packages/structures/src/bitfields/index.ts b/packages/structures/src/bitfields/index.ts
new file mode 100644
index 000000000..011821c8a
--- /dev/null
+++ b/packages/structures/src/bitfields/index.ts
@@ -0,0 +1,4 @@
+export * from './BitField.js';
+
+export * from './ChannelFlagsBitField.js';
+export * from './PermissionsBitField.js';
diff --git a/packages/structures/src/channels/AnnouncementChannel.ts b/packages/structures/src/channels/AnnouncementChannel.ts
new file mode 100644
index 000000000..ccf125155
--- /dev/null
+++ b/packages/structures/src/channels/AnnouncementChannel.ts
@@ -0,0 +1,46 @@
+import type { APINewsChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+
+export interface AnnouncementChannel
+ extends MixinTypes<
+ Channel,
+ [
+ TextChannelMixin,
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ChannelTopicMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for announcement channels, usable by direct end consumers.
+ */
+export class AnnouncementChannel extends Channel<
+ ChannelType.GuildAnnouncement,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(AnnouncementChannel, [
+ TextChannelMixin,
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ChannelTopicMixin,
+]);
diff --git a/packages/structures/src/channels/AnnouncementThreadChannel.ts b/packages/structures/src/channels/AnnouncementThreadChannel.ts
new file mode 100644
index 000000000..418516c9b
--- /dev/null
+++ b/packages/structures/src/channels/AnnouncementThreadChannel.ts
@@ -0,0 +1,49 @@
+import type { APIAnnouncementThreadChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
+
+export interface AnnouncementThreadChannel
+ extends MixinTypes<
+ Channel,
+ [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ GuildChannelMixin,
+ ThreadChannelMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for announcement threads, usable by direct end consumers.
+ */
+export class AnnouncementThreadChannel extends Channel<
+ ChannelType.AnnouncementThread,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData?.(data);
+ }
+}
+
+Mixin(AnnouncementThreadChannel, [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ GuildChannelMixin,
+ ThreadChannelMixin,
+]);
diff --git a/packages/structures/src/channels/CategoryChannel.ts b/packages/structures/src/channels/CategoryChannel.ts
new file mode 100644
index 000000000..56b119635
--- /dev/null
+++ b/packages/structures/src/channels/CategoryChannel.ts
@@ -0,0 +1,28 @@
+import type { APIGuildCategoryChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
+
+export interface CategoryChannel
+ extends MixinTypes<
+ Channel,
+ [ChannelPermissionMixin, GuildChannelMixin]
+ > {}
+
+/**
+ * Sample Implementation of a structure for category channels, usable by direct end consumers.
+ */
+export class CategoryChannel extends Channel<
+ ChannelType.GuildCategory,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(CategoryChannel, [ChannelPermissionMixin, GuildChannelMixin]);
diff --git a/packages/structures/src/channels/Channel.ts b/packages/structures/src/channels/Channel.ts
new file mode 100644
index 000000000..2ea00eacd
--- /dev/null
+++ b/packages/structures/src/channels/Channel.ts
@@ -0,0 +1,185 @@
+import { DiscordSnowflake } from '@sapphire/snowflake';
+import type { APIChannel, APIPartialChannel, ChannelType, ChannelFlags } from 'discord-api-types/v10';
+import { Structure } from '../Structure.js';
+import { ChannelFlagsBitField } from '../bitfields/ChannelFlagsBitField.js';
+import { kData, kPatch } from '../utils/symbols.js';
+import { isIdSet } from '../utils/type-guards.js';
+import type { Partialize } from '../utils/types.js';
+import type { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import type { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
+import type { DMChannelMixin } from './mixins/DMChannelMixin.js';
+import type { GuildChannelMixin } from './mixins/GuildChannelMixin.js';
+import type { TextChannelMixin } from './mixins/TextChannelMixin.js';
+import type { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
+import type { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
+import type { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
+
+export type PartialChannel = Channel>;
+
+/**
+ * The data stored by a {@link Channel} structure based on its {@link (Channel:class)."type"} property.
+ */
+export type ChannelDataType = Type extends ChannelType
+ ? Extract
+ : APIPartialChannel;
+
+/**
+ * Represents any channel on Discord.
+ *
+ * @typeParam Type - Specify the type of the channel being constructed for more accurate data types
+ * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
+ * @remarks Although this class _can_ be instantiated directly for any channel type,
+ * it's intended to be subclassed with the appropriate mixins for each channel type.
+ */
+export class Channel<
+ Type extends ChannelType | 'unknown' = ChannelType,
+ Omitted extends keyof ChannelDataType | '' = '',
+> extends Structure, Omitted> {
+ /**
+ * The template used for removing data from the raw data stored for each Channel.
+ *
+ * @remarks This template is only guaranteed to apply to channels constructed directly via `new Channel()`.
+ * Use the appropriate subclass template to remove data from that channel type.
+ */
+ public static override readonly DataTemplate: Partial = {};
+
+ /**
+ * @param data - The raw data received from the API for the channel
+ */
+ public constructor(data: Partialize, Omitted>) {
+ super(data as ChannelDataType);
+ }
+
+ /**
+ * {@inheritDoc Structure.[kPatch]}
+ *
+ * @internal
+ */
+ public override [kPatch](data: Partial>) {
+ return super[kPatch](data);
+ }
+
+ /**
+ * The id of the channel
+ */
+ public get id() {
+ return this[kData].id;
+ }
+
+ /**
+ * The type of the channel
+ */
+ public get type() {
+ // This cast can be incorrect when type is omitted and if the wrong type of channel was constructed
+ return this[kData].type as Type extends 'unknown' ? number : Type;
+ }
+
+ /**
+ * The name of the channel, null for DMs
+ *
+ * @privateRemarks The type of `name` can be narrowed in Guild Channels and DM channels to string and null respectively,
+ * respecting Omit behaviors
+ */
+ public get name() {
+ return this[kData].name;
+ }
+
+ /**
+ * The flags that are applied to the channel.
+ *
+ * @privateRemarks The type of `flags` can be narrowed in Guild Channels and DMChannel to ChannelFlags, and in GroupDM channel
+ * to null, respecting Omit behaviors
+ */
+ public get flags() {
+ const flags =
+ 'flags' in this[kData] && typeof this[kData].flags === 'number' ? (this[kData].flags as ChannelFlags) : null;
+ return flags ? new ChannelFlagsBitField(flags) : null;
+ }
+
+ /**
+ * The timestamp the channel was created at
+ */
+ public get createdTimestamp() {
+ return isIdSet(this.id) ? DiscordSnowflake.timestampFrom(this.id) : null;
+ }
+
+ /**
+ * The time the channel was created at
+ */
+ public get createdAt() {
+ const createdTimestamp = this.createdTimestamp;
+ return createdTimestamp ? new Date(createdTimestamp) : null;
+ }
+
+ /**
+ * Indicates whether this channel is a thread channel
+ *
+ * @privateRemarks Overridden to `true` on `ThreadChannelMixin`
+ */
+ public isThread(): this is ThreadChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel can contain messages
+ *
+ * @privateRemarks Overridden to `true` on `TextChannelMixin`
+ */
+ public isTextBased(): this is TextChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel is in a guild
+ *
+ * @privateRemarks Overridden to `true` on `GuildChannelMixin`
+ */
+ public isGuildBased(): this is GuildChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel is a DM or DM Group
+ *
+ * @privateRemarks Overridden to `true` on `DMChannelMixin`
+ */
+ public isDMBased(): this is DMChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel has voice connection capabilities
+ *
+ * @privateRemarks Overridden to `true` on `VoiceChannelMixin`
+ */
+ public isVoiceBased(): this is VoiceChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel only allows thread creation
+ *
+ * @privateRemarks Overridden to `true` on `ThreadOnlyChannelMixin`
+ */
+ public isThreadOnly(): this is ThreadOnlyChannelMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel can have permission overwrites
+ *
+ * @privateRemarks Overridden to `true` on `ChannelPermissionsMixin`
+ */
+ public isPermissionCapable(): this is ChannelPermissionMixin & this {
+ return false;
+ }
+
+ /**
+ * Indicates whether this channel can have webhooks
+ *
+ * @privateRemarks Overridden to `true` on `ChannelWebhooksMixin`
+ */
+ public isWebhookCapable(): this is ChannelWebhookMixin & this {
+ return false;
+ }
+}
diff --git a/packages/structures/src/channels/DMChannel.ts b/packages/structures/src/channels/DMChannel.ts
new file mode 100644
index 000000000..c679b2caa
--- /dev/null
+++ b/packages/structures/src/channels/DMChannel.ts
@@ -0,0 +1,26 @@
+import type { APIDMChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { DMChannelMixin } from './mixins/DMChannelMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+
+export interface DMChannel
+ extends MixinTypes<
+ Channel,
+ [DMChannelMixin, TextChannelMixin, ChannelPinMixin]
+ > {}
+
+/**
+ * Sample Implementation of a structure for dm channels, usable by direct end consumers.
+ */
+export class DMChannel extends Channel {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(DMChannel, [DMChannelMixin, TextChannelMixin, ChannelPinMixin]);
diff --git a/packages/structures/src/channels/ForumChannel.ts b/packages/structures/src/channels/ForumChannel.ts
new file mode 100644
index 000000000..6dce1db5a
--- /dev/null
+++ b/packages/structures/src/channels/ForumChannel.ts
@@ -0,0 +1,44 @@
+import type { APIGuildForumChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import { kData } from '../utils/symbols.js';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
+import { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
+
+export interface ForumChannel
+ extends MixinTypes<
+ Channel,
+ [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelTopicMixin,
+ ThreadOnlyChannelMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for forum channels, usable by direct end consumers.
+ */
+export class ForumChannel extends Channel<
+ ChannelType.GuildForum,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+
+ /**
+ * The default forum layout view used to display posts in this channel.
+ * Defaults to 0, which indicates a layout view has not been set by a channel admin.
+ */
+ public get defaultForumLayout() {
+ return this[kData].default_forum_layout;
+ }
+}
+
+Mixin(ForumChannel, [ChannelParentMixin, ChannelPermissionMixin, ChannelTopicMixin, ThreadOnlyChannelMixin]);
diff --git a/packages/structures/src/channels/ForumTag.ts b/packages/structures/src/channels/ForumTag.ts
new file mode 100644
index 000000000..feee01f82
--- /dev/null
+++ b/packages/structures/src/channels/ForumTag.ts
@@ -0,0 +1,57 @@
+import type { APIGuildForumTag } from 'discord-api-types/v10';
+import { Structure } from '../Structure.js';
+import { kData } from '../utils/symbols.js';
+import type { Partialize } from '../utils/types.js';
+
+/**
+ * Represents metadata of a thread channel on Discord.
+ *
+ * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
+ */
+export class ForumTag extends Structure {
+ public constructor(data: Partialize) {
+ super(data);
+ }
+
+ /**
+ * The id of the tag.
+ */
+ public get id() {
+ return this[kData].id;
+ }
+
+ /**
+ * The name of the tag.
+ */
+ public get name() {
+ return this[kData].name;
+ }
+
+ /**
+ * Whether this tag can only be added to or removed from threads by a member with the {@link discord-api-types/v10#(PermissionFlagsBits:variable) | ManageThreads} permission.
+ */
+ public get moderated() {
+ return this[kData].moderated;
+ }
+
+ /**
+ * The id of a guild's custom emoji.
+ */
+ public get emojiId() {
+ return this[kData].emoji_id;
+ }
+
+ /**
+ * The unicode character of the emoji.
+ */
+ public get emojiName() {
+ return this[kData].emoji_name;
+ }
+
+ /**
+ * The textual representation of this tag's emoji. Either a unicode character or a guild emoji mention.
+ */
+ public get emoji() {
+ return this.emojiName ?? `<:_:${this.emojiId}>`;
+ }
+}
diff --git a/packages/structures/src/channels/GroupDMChannel.ts b/packages/structures/src/channels/GroupDMChannel.ts
new file mode 100644
index 000000000..ecdb05bab
--- /dev/null
+++ b/packages/structures/src/channels/GroupDMChannel.ts
@@ -0,0 +1,35 @@
+import type { APIGroupDMChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
+import { DMChannelMixin } from './mixins/DMChannelMixin.js';
+import { GroupDMMixin } from './mixins/GroupDMMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+
+export interface GroupDMChannel
+ extends MixinTypes<
+ Channel,
+ [
+ DMChannelMixin,
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ GroupDMMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for group dm channels, usable by direct end consumers.
+ */
+export class GroupDMChannel extends Channel<
+ ChannelType.GroupDM,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(GroupDMChannel, [DMChannelMixin, TextChannelMixin, ChannelOwnerMixin, GroupDMMixin]);
diff --git a/packages/structures/src/channels/MediaChannel.ts b/packages/structures/src/channels/MediaChannel.ts
new file mode 100644
index 000000000..96d4add59
--- /dev/null
+++ b/packages/structures/src/channels/MediaChannel.ts
@@ -0,0 +1,35 @@
+import type { APIGuildMediaChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
+import { ThreadOnlyChannelMixin } from './mixins/ThreadOnlyChannelMixin.js';
+
+export interface MediaChannel
+ extends MixinTypes<
+ Channel,
+ [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelTopicMixin,
+ ThreadOnlyChannelMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for media channels, usable by direct end consumers.
+ */
+export class MediaChannel extends Channel<
+ ChannelType.GuildMedia,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(MediaChannel, [ChannelParentMixin, ChannelPermissionMixin, ChannelTopicMixin, ThreadOnlyChannelMixin]);
diff --git a/packages/structures/src/channels/PermissionOverwrite.ts b/packages/structures/src/channels/PermissionOverwrite.ts
new file mode 100644
index 000000000..3d2aee147
--- /dev/null
+++ b/packages/structures/src/channels/PermissionOverwrite.ts
@@ -0,0 +1,94 @@
+import type { APIOverwrite } from 'discord-api-types/v10';
+import { Structure } from '../Structure.js';
+import { PermissionsBitField } from '../bitfields/PermissionsBitField.js';
+import { kAllow, kData, kDeny } from '../utils/symbols.js';
+import type { Partialize } from '../utils/types.js';
+
+/**
+ * Represents metadata of a thread channel on Discord.
+ *
+ * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
+ */
+export class PermissionOverwrite extends Structure<
+ APIOverwrite,
+ Omitted
+> {
+ protected [kAllow]: bigint | null = null;
+
+ protected [kDeny]: bigint | null = null;
+
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+
+ /**
+ * The template used for removing data from the raw data stored for each ThreadMetadata
+ *
+ * @remarks This template has defaults, if you want to remove additional data and keep the defaults,
+ * use `Object.defineProperties`. To override the defaults, set this value directly.
+ */
+ public static override readonly DataTemplate: Partial = {
+ set allow(_: string) {},
+ set deny(_: string) {},
+ };
+
+ /**
+ * {@inheritDoc Structure.optimizeData}
+ */
+ protected override optimizeData(data: Partial) {
+ if (data.allow) {
+ this[kAllow] = BigInt(data.allow);
+ }
+
+ if (data.deny) {
+ this[kDeny] = BigInt(data.deny);
+ }
+ }
+
+ /**
+ * The permission bit set allowed by this overwrite.
+ */
+ public get allow() {
+ const allow = this[kAllow];
+ return typeof allow === 'bigint' ? new PermissionsBitField(allow) : null;
+ }
+
+ /**
+ * The permission bit set denied by this overwrite.
+ */
+ public get deny() {
+ const deny = this[kDeny];
+ return typeof deny === 'bigint' ? new PermissionsBitField(deny) : null;
+ }
+
+ /**
+ * The role or user id for this overwrite.
+ */
+ public get id() {
+ return this[kData].id;
+ }
+
+ /**
+ * The type of this overwrite.
+ */
+ public get type() {
+ return this[kData].type;
+ }
+
+ /**
+ * {@inheritDoc Structure.toJSON}
+ */
+ public override toJSON() {
+ const clone = super.toJSON();
+ if (this[kAllow]) {
+ clone.allow = this[kAllow].toString();
+ }
+
+ if (this[kDeny]) {
+ clone.deny = this[kDeny].toString();
+ }
+
+ return clone;
+ }
+}
diff --git a/packages/structures/src/channels/PrivateThreadChannel.ts b/packages/structures/src/channels/PrivateThreadChannel.ts
new file mode 100644
index 000000000..577c0b805
--- /dev/null
+++ b/packages/structures/src/channels/PrivateThreadChannel.ts
@@ -0,0 +1,46 @@
+import type { APIPrivateThreadChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
+
+export interface PrivateThreadChannel
+ extends MixinTypes<
+ Channel,
+ [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ThreadChannelMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for private thread channels, usable by direct end consumers.
+ */
+export class PrivateThreadChannel extends Channel<
+ ChannelType.PrivateThread,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(PrivateThreadChannel, [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ThreadChannelMixin,
+]);
diff --git a/packages/structures/src/channels/PublicThreadChannel.ts b/packages/structures/src/channels/PublicThreadChannel.ts
new file mode 100644
index 000000000..f2600eb55
--- /dev/null
+++ b/packages/structures/src/channels/PublicThreadChannel.ts
@@ -0,0 +1,49 @@
+import type { APIPublicThreadChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { AppliedTagsMixin } from './mixins/AppliedTagsMixin.js';
+import { ChannelOwnerMixin } from './mixins/ChannelOwnerMixin.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+import { ThreadChannelMixin } from './mixins/ThreadChannelMixin.js';
+
+export interface PublicThreadChannel
+ extends MixinTypes<
+ Channel,
+ [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ThreadChannelMixin,
+ AppliedTagsMixin,
+ ]
+ > {}
+
+/**
+ * Sample Implementation of a structure for public thread channels, usable by direct end consumers.
+ */
+export class PublicThreadChannel extends Channel<
+ ChannelType.PublicThread,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(PublicThreadChannel, [
+ TextChannelMixin,
+ ChannelOwnerMixin,
+ ChannelParentMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ThreadChannelMixin,
+ AppliedTagsMixin,
+]);
diff --git a/packages/structures/src/channels/StageChannel.ts b/packages/structures/src/channels/StageChannel.ts
new file mode 100644
index 000000000..a49fbcbc3
--- /dev/null
+++ b/packages/structures/src/channels/StageChannel.ts
@@ -0,0 +1,40 @@
+import type { APIGuildStageVoiceChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
+import { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
+
+export interface StageChannel
+ extends MixinTypes<
+ Channel,
+ [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelSlowmodeMixin,
+ ChannelWebhookMixin,
+ VoiceChannelMixin,
+ ]
+ > {}
+
+export class StageChannel extends Channel<
+ ChannelType.GuildStageVoice,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(StageChannel, [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelSlowmodeMixin,
+ ChannelWebhookMixin,
+ VoiceChannelMixin,
+]);
diff --git a/packages/structures/src/channels/TextChannel.ts b/packages/structures/src/channels/TextChannel.ts
new file mode 100644
index 000000000..75d0b7c39
--- /dev/null
+++ b/packages/structures/src/channels/TextChannel.ts
@@ -0,0 +1,43 @@
+import type { APITextChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelPinMixin } from './mixins/ChannelPinMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { ChannelTopicMixin } from './mixins/ChannelTopicMixin.js';
+import { TextChannelMixin } from './mixins/TextChannelMixin.js';
+
+export interface TextChannel
+ extends MixinTypes<
+ Channel,
+ [
+ TextChannelMixin,
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ChannelTopicMixin,
+ ]
+ > {}
+
+export class TextChannel extends Channel<
+ ChannelType.GuildText,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(TextChannel, [
+ TextChannelMixin,
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelPinMixin,
+ ChannelSlowmodeMixin,
+ ChannelTopicMixin,
+]);
diff --git a/packages/structures/src/channels/ThreadMetadata.ts b/packages/structures/src/channels/ThreadMetadata.ts
new file mode 100644
index 000000000..433c90122
--- /dev/null
+++ b/packages/structures/src/channels/ThreadMetadata.ts
@@ -0,0 +1,120 @@
+import type { APIThreadMetadata } from 'discord-api-types/v10';
+import { Structure } from '../Structure.js';
+import { kArchiveTimestamp, kCreatedTimestamp, kData } from '../utils/symbols.js';
+import type { Partialize } from '../utils/types.js';
+
+/**
+ * Represents metadata of a thread channel on Discord.
+ *
+ * @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
+ */
+export class ThreadMetadata<
+ Omitted extends keyof APIThreadMetadata | '' = 'archive_timestamp' | 'create_timestamp',
+> extends Structure {
+ protected [kArchiveTimestamp]: number | null = null;
+
+ protected [kCreatedTimestamp]: number | null = null;
+
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+
+ /**
+ * The template used for removing data from the raw data stored for each ThreadMetadata
+ *
+ * @remarks This template has defaults, if you want to remove additional data and keep the defaults,
+ * use `Object.defineProperties`. To override the defaults, set this value directly.
+ */
+ public static override readonly DataTemplate: Partial = {
+ set create_timestamp(_: string) {},
+ set archive_timestamp(_: string) {},
+ };
+
+ /**
+ * {@inheritDoc Structure.optimizeData}
+ */
+ protected override optimizeData(data: Partial) {
+ if (data.create_timestamp) {
+ this[kCreatedTimestamp] = Date.parse(data.create_timestamp);
+ }
+
+ if (data.archive_timestamp) {
+ this[kArchiveTimestamp] = Date.parse(data.archive_timestamp);
+ }
+ }
+
+ /**
+ * Whether the thread is archived.
+ */
+ public get archived() {
+ return this[kData].archived;
+ }
+
+ /**
+ * The timestamp when the thread's archive status was last changed, used for calculating recent activity.
+ */
+ public get archivedTimestamp() {
+ return this[kArchiveTimestamp];
+ }
+
+ /**
+ * The timestamp when the thread was created; only populated for threads created after 2022-01-09.
+ */
+ public get createdTimestamp() {
+ return this[kCreatedTimestamp];
+ }
+
+ /**
+ * The thread will stop showing in the channel list after auto_archive_duration minutes of inactivity,
+ */
+ public get autoArchiveDuration() {
+ return this[kData].auto_archive_duration;
+ }
+
+ /**
+ * Whether non-moderators can add other non-moderators to a thread; only available on private threads.
+ */
+ public get invitable() {
+ return this[kData].invitable;
+ }
+
+ /**
+ * Whether the thread is locked; when a thread is locked, only users with {@link discord-api-types/v10#(PermissionFlagsBits:variable) | ManageThreads} can unarchive it.
+ */
+ public get locked() {
+ return this[kData].locked;
+ }
+
+ /**
+ * The time the thread was archived at
+ */
+ public get archivedAt() {
+ const archivedTimestamp = this.archivedTimestamp;
+ return archivedTimestamp ? new Date(archivedTimestamp) : null;
+ }
+
+ /**
+ * The time the thread was created at
+ */
+ public get createdAt() {
+ const createdTimestamp = this.createdTimestamp;
+ return createdTimestamp ? new Date(createdTimestamp) : null;
+ }
+
+ /**
+ * {@inheritDoc Structure.toJSON}
+ */
+ public override toJSON() {
+ const data = super.toJSON();
+ if (this[kArchiveTimestamp]) {
+ data.archive_timestamp = new Date(this[kArchiveTimestamp]).toISOString();
+ }
+
+ if (this[kCreatedTimestamp]) {
+ data.create_timestamp = new Date(this[kCreatedTimestamp]).toISOString();
+ }
+
+ return data;
+ }
+}
diff --git a/packages/structures/src/channels/VoiceChannel.ts b/packages/structures/src/channels/VoiceChannel.ts
new file mode 100644
index 000000000..a14de1320
--- /dev/null
+++ b/packages/structures/src/channels/VoiceChannel.ts
@@ -0,0 +1,40 @@
+import type { APIGuildVoiceChannel, ChannelType } from 'discord-api-types/v10';
+import { Mixin } from '../Mixin.js';
+import type { MixinTypes } from '../MixinTypes.d.ts';
+import type { Partialize } from '../utils/types.js';
+import { Channel } from './Channel.js';
+import { ChannelParentMixin } from './mixins/ChannelParentMixin.js';
+import { ChannelPermissionMixin } from './mixins/ChannelPermissionMixin.js';
+import { ChannelSlowmodeMixin } from './mixins/ChannelSlowmodeMixin.js';
+import { ChannelWebhookMixin } from './mixins/ChannelWebhookMixin.js';
+import { VoiceChannelMixin } from './mixins/VoiceChannelMixin.js';
+
+export interface VoiceChannel
+ extends MixinTypes<
+ Channel,
+ [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelSlowmodeMixin,
+ ChannelWebhookMixin,
+ VoiceChannelMixin,
+ ]
+ > {}
+
+export class VoiceChannel extends Channel<
+ ChannelType.GuildVoice,
+ Omitted
+> {
+ public constructor(data: Partialize) {
+ super(data);
+ this.optimizeData(data);
+ }
+}
+
+Mixin(VoiceChannel, [
+ ChannelParentMixin,
+ ChannelPermissionMixin,
+ ChannelSlowmodeMixin,
+ ChannelWebhookMixin,
+ VoiceChannelMixin,
+]);
diff --git a/packages/structures/src/channels/index.ts b/packages/structures/src/channels/index.ts
new file mode 100644
index 000000000..b4f8d9bbc
--- /dev/null
+++ b/packages/structures/src/channels/index.ts
@@ -0,0 +1,21 @@
+export * from './mixins/index.js';
+
+export * from './ForumTag.js';
+export * from './PermissionOverwrite.js';
+export * from './ThreadMetadata.js';
+
+export * from './Channel.js';
+
+export * from './AnnouncementChannel.js';
+export * from './AnnouncementThreadChannel.js';
+export * from './CategoryChannel.js';
+// export * from './DirectoryChannel.js';
+export * from './DMChannel.js';
+export * from './ForumChannel.js';
+export * from './GroupDMChannel.js';
+export * from './MediaChannel.js';
+export * from './PrivateThreadChannel.js';
+export * from './PublicThreadChannel.js';
+export * from './StageChannel.js';
+export * from './TextChannel.js';
+export * from './VoiceChannel.js';
diff --git a/packages/structures/src/channels/mixins/AppliedTagsMixin.ts b/packages/structures/src/channels/mixins/AppliedTagsMixin.ts
new file mode 100644
index 000000000..14fcf91f0
--- /dev/null
+++ b/packages/structures/src/channels/mixins/AppliedTagsMixin.ts
@@ -0,0 +1,14 @@
+import type { ChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface AppliedTagsMixin extends Channel {}
+
+export class AppliedTagsMixin {
+ /**
+ * The ids of the set of tags that have been applied to a thread in a {@link (ForumChannel:class)} or a {@link (MediaChannel:class)}.
+ */
+ public get appliedTags(): readonly string[] | null {
+ return Array.isArray(this[kData].applied_tags) ? this[kData].applied_tags : null;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts b/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts
new file mode 100644
index 000000000..bc3c07032
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelOwnerMixin.ts
@@ -0,0 +1,14 @@
+import type { ChannelType, ThreadChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface ChannelOwnerMixin extends Channel {}
+
+export class ChannelOwnerMixin {
+ /**
+ * The id of the creator of the group DM or thread
+ */
+ public get ownerId() {
+ return this[kData].owner_id;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelParentMixin.ts b/packages/structures/src/channels/mixins/ChannelParentMixin.ts
new file mode 100644
index 000000000..aa1a98456
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelParentMixin.ts
@@ -0,0 +1,21 @@
+import type { ChannelType, GuildChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import { GuildChannelMixin } from './GuildChannelMixin.js';
+
+export class ChannelParentMixin<
+ Type extends Exclude,
+> extends GuildChannelMixin {
+ /**
+ * The id of the parent category for a channel (each parent category can contain up to 50 channels) or id of the parent channel for a thread
+ */
+ public get parentId() {
+ return this[kData].parent_id;
+ }
+
+ /**
+ * Whether the channel is nsfw
+ */
+ public get nsfw() {
+ return this[kData].nsfw;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts b/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts
new file mode 100644
index 000000000..ada4415d4
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelPermissionMixin.ts
@@ -0,0 +1,34 @@
+import type { ChannelType, GuildChannelType, ThreadChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface ChannelPermissionMixin<
+ Type extends Exclude = Exclude<
+ GuildChannelType,
+ ChannelType.GuildDirectory | ThreadChannelType
+ >,
+> extends Channel {}
+
+/**
+ * @remarks has an array of sub-structures {@link PermissionOverwrite} that extending mixins should add to their DataTemplate and _optimizeData
+ */
+export class ChannelPermissionMixin<
+ Type extends Exclude = Exclude<
+ GuildChannelType,
+ ChannelType.GuildDirectory | ThreadChannelType
+ >,
+> {
+ /**
+ * The sorting position of the channel
+ */
+ public get position() {
+ return this[kData].position;
+ }
+
+ /**
+ * Indicates whether this channel can have permission overwrites
+ */
+ public isPermissionCapable(): this is ChannelPermissionMixin & this {
+ return true;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelPinMixin.ts b/packages/structures/src/channels/mixins/ChannelPinMixin.ts
new file mode 100644
index 000000000..d82051c14
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelPinMixin.ts
@@ -0,0 +1,62 @@
+import type { ChannelType, ThreadChannelType } from 'discord-api-types/v10';
+import { kLastPinTimestamp, kMixinConstruct, kMixinToJSON } from '../../utils/symbols.js';
+import type { Channel, ChannelDataType } from '../Channel.js';
+
+export interface ChannelPinMixin<
+ Type extends ChannelType.DM | ChannelType.GuildAnnouncement | ChannelType.GuildText | ThreadChannelType,
+> extends Channel {}
+
+export class ChannelPinMixin<
+ Type extends ChannelType.DM | ChannelType.GuildAnnouncement | ChannelType.GuildText | ThreadChannelType,
+> {
+ /**
+ * The timestamp of when the last pin in the channel happened
+ */
+ declare protected [kLastPinTimestamp]: number | null;
+
+ /**
+ * The template used for removing data from the raw data stored for each Channel.
+ */
+ public static readonly DataTemplate: Partial<
+ ChannelDataType
+ > = {
+ set last_pin_timestamp(_: string) {},
+ };
+
+ public [kMixinConstruct]() {
+ this[kLastPinTimestamp] ??= null;
+ }
+
+ /**
+ * {@inheritDoc Structure.optimizeData}
+ */
+ protected optimizeData(data: Partial>) {
+ if (data.last_pin_timestamp) {
+ this[kLastPinTimestamp] = Date.parse(data.last_pin_timestamp);
+ }
+ }
+
+ /**
+ * The timestamp of when the last pin in the channel happened.
+ */
+ public get lastPinTimestamp() {
+ return this[kLastPinTimestamp];
+ }
+
+ /**
+ * The Date of when the last pin in the channel happened
+ */
+ public get lastPinAt() {
+ const lastPinTimestamp = this.lastPinTimestamp;
+ return lastPinTimestamp ? new Date(lastPinTimestamp) : null;
+ }
+
+ /**
+ * Adds data from optimized properties omitted from [kData].
+ *
+ * @param data - the result of {@link (Structure:class).toJSON}
+ */
+ protected [kMixinToJSON](data: Partial>) {
+ data.last_pin_timestamp = this[kLastPinTimestamp] ? new Date(this[kLastPinTimestamp]).toISOString() : null;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts b/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts
new file mode 100644
index 000000000..688a38039
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelSlowmodeMixin.ts
@@ -0,0 +1,12 @@
+import type { GuildTextChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import { TextChannelMixin } from './TextChannelMixin.js';
+
+export class ChannelSlowmodeMixin extends TextChannelMixin {
+ /**
+ * The rate limit per user (slowmode) of this channel.
+ */
+ public get rateLimitPerUser() {
+ return this[kData].rate_limit_per_user;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelTopicMixin.ts b/packages/structures/src/channels/mixins/ChannelTopicMixin.ts
new file mode 100644
index 000000000..b28072fbe
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelTopicMixin.ts
@@ -0,0 +1,33 @@
+import type { ChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+import { ChannelWebhookMixin } from './ChannelWebhookMixin.js';
+
+export interface ChannelTopicMixin<
+ Type extends ChannelType.GuildAnnouncement | ChannelType.GuildForum | ChannelType.GuildMedia | ChannelType.GuildText,
+> extends Channel {}
+
+export class ChannelTopicMixin<
+ Type extends ChannelType.GuildAnnouncement | ChannelType.GuildForum | ChannelType.GuildMedia | ChannelType.GuildText,
+> extends ChannelWebhookMixin {
+ /**
+ * The topic of this channel.
+ */
+ public get topic() {
+ return this[kData].topic;
+ }
+
+ /**
+ * The duration after which new threads get archived by default on this channel.
+ */
+ public get defaultAutoArchiveDuration() {
+ return this[kData].default_auto_archive_duration;
+ }
+
+ /**
+ * The default value for rate limit per user (slowmode) on new threads in this channel.
+ */
+ public get defaultThreadRateLimitPerUser() {
+ return this[kData].default_thread_rate_limit_per_user;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts b/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts
new file mode 100644
index 000000000..31f191329
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ChannelWebhookMixin.ts
@@ -0,0 +1,23 @@
+import type { ChannelType, GuildTextChannelType, ThreadChannelType } from 'discord-api-types/v10';
+import type { Channel } from '../Channel.js';
+
+export interface ChannelWebhookMixin<
+ Type extends ChannelType.GuildForum | ChannelType.GuildMedia | Exclude =
+ | ChannelType.GuildForum
+ | ChannelType.GuildMedia
+ | Exclude,
+> extends Channel {}
+
+export class ChannelWebhookMixin<
+ Type extends ChannelType.GuildForum | ChannelType.GuildMedia | Exclude =
+ | ChannelType.GuildForum
+ | ChannelType.GuildMedia
+ | Exclude,
+> {
+ /**
+ * Indicates whether this channel can have webhooks
+ */
+ public isWebhookCapable(): this is ChannelWebhookMixin & this {
+ return true;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/DMChannelMixin.ts b/packages/structures/src/channels/mixins/DMChannelMixin.ts
new file mode 100644
index 000000000..3f88f6e69
--- /dev/null
+++ b/packages/structures/src/channels/mixins/DMChannelMixin.ts
@@ -0,0 +1,27 @@
+import { channelLink } from '@discordjs/formatters';
+import type { ChannelType } from 'discord-api-types/v10';
+import type { User } from '../../users/User.js';
+import type { Channel } from '../Channel.js';
+
+export interface DMChannelMixin<
+ Type extends ChannelType.DM | ChannelType.GroupDM = ChannelType.DM | ChannelType.GroupDM,
+> extends Channel {}
+
+/**
+ * @remarks has recipients, an array of sub-structures {@link User} that extending mixins should add to their DataTemplate and _optimizeData
+ */
+export class DMChannelMixin {
+ /**
+ * The URL to this channel.
+ */
+ public get url() {
+ return channelLink(this.id);
+ }
+
+ /**
+ * Indicates whether this channel is a DM or DM Group
+ */
+ public isDMBased(): this is DMChannelMixin & this {
+ return true;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/GroupDMMixin.ts b/packages/structures/src/channels/mixins/GroupDMMixin.ts
new file mode 100644
index 000000000..ea98c95f3
--- /dev/null
+++ b/packages/structures/src/channels/mixins/GroupDMMixin.ts
@@ -0,0 +1,28 @@
+import type { ChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface GroupDMMixin extends Channel {}
+
+export class GroupDMMixin {
+ /**
+ * The icon hash of the group DM.
+ */
+ public get icon() {
+ return this[kData].icon;
+ }
+
+ /**
+ * Whether the channel is managed by an application via the `gdm.join` OAuth2 scope.
+ */
+ public get managed() {
+ return this[kData].managed;
+ }
+
+ /**
+ * The application id of the group DM creator if it is bot-created.
+ */
+ public get applicationId() {
+ return this[kData].application_id;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/GuildChannelMixin.ts b/packages/structures/src/channels/mixins/GuildChannelMixin.ts
new file mode 100644
index 000000000..fd42a1958
--- /dev/null
+++ b/packages/structures/src/channels/mixins/GuildChannelMixin.ts
@@ -0,0 +1,40 @@
+import { channelLink } from '@discordjs/formatters';
+import type { GuildChannelType } from 'discord-api-types/v10';
+import { ChannelFlagsBitField } from '../../bitfields/ChannelFlagsBitField.js';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface GuildChannelMixin extends Channel {}
+
+export class GuildChannelMixin {
+ /**
+ * The flags that are applied to the channel.
+ *
+ * @privateRemarks The type of `flags` can be narrowed in Guild Channels and DMChannel to ChannelFlags, and in GroupDM channel
+ * to null, respecting Omit behaviors
+ */
+ public get flags() {
+ return this[kData].flags ? new ChannelFlagsBitField(this[kData].flags) : null;
+ }
+
+ /**
+ * THe id of the guild this channel is in.
+ */
+ public get guildId() {
+ return this[kData].guild_id!;
+ }
+
+ /**
+ * The URL to this channel.
+ */
+ public get url() {
+ return channelLink(this.id, this.guildId);
+ }
+
+ /**
+ * Indicates whether this channel is in a guild
+ */
+ public isGuildBased(): this is GuildChannelMixin & this {
+ return true;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/TextChannelMixin.ts b/packages/structures/src/channels/mixins/TextChannelMixin.ts
new file mode 100644
index 000000000..c9552171e
--- /dev/null
+++ b/packages/structures/src/channels/mixins/TextChannelMixin.ts
@@ -0,0 +1,21 @@
+import type { TextChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface TextChannelMixin extends Channel {}
+
+export class TextChannelMixin {
+ /**
+ * The id of the last message sent in this channel.
+ */
+ public get lastMessageId() {
+ return this[kData].last_message_id;
+ }
+
+ /**
+ * Indicates whether this channel can contain messages
+ */
+ public isTextBased(): this is TextChannelMixin & this {
+ return true;
+ }
+}
diff --git a/packages/structures/src/channels/mixins/ThreadChannelMixin.ts b/packages/structures/src/channels/mixins/ThreadChannelMixin.ts
new file mode 100644
index 000000000..ce8f17909
--- /dev/null
+++ b/packages/structures/src/channels/mixins/ThreadChannelMixin.ts
@@ -0,0 +1,39 @@
+import type { ThreadChannelType } from 'discord-api-types/v10';
+import { kData } from '../../utils/symbols.js';
+import type { Channel } from '../Channel.js';
+
+export interface ThreadChannelMixin extends Channel {}
+
+/**
+ * @remarks has a sub-structure {@link ThreadMetadata} that extending mixins should add to their DataTemplate and _optimizeData
+ */
+export class ThreadChannelMixin