Repository: scimmyjs/scimmy (js)

Issue:
Typescript types problems
We upgraded to the latest version (1.3.1). However there are still some issues trying to use the types.

## Import won't use the right file

```
Could not find a declaration file for module 'scimmy'. 'xxx/node_modules/scimmy/dist/cjs/scimmy.cjs' implicitly has an 'any' type.   
Try `npm i --save-dev @types/scimmy` if it exists or add a new declaration (.d.ts) file containing `declare module 'scimmy';`ts(7016)
```
if we import using `import { Resources } from "scimmy";` or `import SCIMMY from "scimmy";`

After copying the type file to a modules declarations file and making a few adjustments some types are working. However we a running in the following problems:

## Resource as any

```ts
Resources.declare(Resources.User)
    .extend(SCIMMY.Schemas.EnterpriseUser, false)
    .ingress(async (resource, data, ctx: Context) => {})
```
or

```ts
User.extend(Schemas.EnterpriseUser, false)
    .ingress(async (resource, data, ctx: Context) => {})
```

Will show the ingress/egress/... resource/data as any type.

## Enterprise Types

```ts
export type ScimUser = Omit<SUser, ShadowAttributes> & Omit<EnterpriseUser, ShadowAttributes> & {
    // eslint-disable-next-line @typescript-eslint/naming-convention
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
        department?: string;
    }
}
```

Getting a valid Enterprise User might need something like the above typing.

## Group degress handler

The degress handler of Groups won't allow for promises as the method always require a void return instead of Promise<void>.


Pull request:
Refine TypeScript types and address type resolution issues
Currently, when using TypeScript, type resolution will fail if the `moduleResolution` compiler option in tsconfig.json is set to nodenext, and the `type` property in a project's package.json is set to anything other than `module`. This is due to the `nodenext` module resolution strategy, which requires explicit definition of the TypeScript `types` location for both ESM `import` and CJS `require` exports in package.json, and will ignore the previously used top-level `types` package.json property.

Additionally, due to deficiencies in TypeScript's understanding of inherited class static methods, chained class static method calls following calls to the inherited `extend` method in the `User` and `Group` resource classes lose their correct target class. This causes all types to fall back to using types declared on the base `Resource` class, which are not intended to be used directly.

Furthermore, when working with resources whose schemas have been extended, there is currently no easy means of describing the expected shape of the `instance` argument or return value shape of ingress/egress handler implementations. This means they cannot easily be type-checked.

This change adds explicit `types` fields, pointing to the common type definition file, for each of the ESM/CJS exports in the SCIMMY package.json file (fixes #63 pt. 1). JSDoc type annotations have also been updated to explicitly declare the static `extend` method on the `User` and `Group` resource classes, and the static ingress/egress methods have been made generic, accepting a single type parameter describing the shape of instance and return values (fixes #63 pt. 2). An additional type annotation, `SCIMMY.Types.Schema.Extended`, has also been added to facilitate working with resources whose schemas have been extended (fixes #63 pt. 3). More refined type annotations have also been sprinkled throughout, where previously properties or parameters were declared as being of type "any".

Patch:
diff --git a/package-lock.json b/package-lock.json
index 330863e..6bc3c11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,7 @@
         "jsdoc": "^4.0.2",
         "minimist": "^1.2.8",
         "mocha": "^10.4.0",
-        "ostensibly-typed": "^1.0.3",
+        "ostensibly-typed": "^1.2.0",
         "rollup": "^4.25.0",
         "sinon": "^19.0.2",
         "typescript": "^5.4.5"
@@ -1767,9 +1767,9 @@
       }
     },
     "node_modules/ostensibly-typed": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/ostensibly-typed/-/ostensibly-typed-1.0.3.tgz",
-      "integrity": "sha512-9xiB7kV1Uhig0vB8mwgJF62JJt/Z1ooOma+Lvg6EpG7Ni98GODi1US5aWEU7RvegafghItlsUPYyIQY6fY+B7g==",
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/ostensibly-typed/-/ostensibly-typed-1.2.0.tgz",
+      "integrity": "sha512-JxfyIwDZHARwW+0ebPsT2TQc4+Ev7OfAoJ3zlna455Vos5UTSyqm4DtJDC6WPsufhWM1prHWC6gTTiwoGR7A6Q==",
       "dev": true,
       "peerDependencies": {
         "typescript": ">=5.6"
diff --git a/package.json b/package.json
index ac18a70..a76f966 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,24 @@
   "types": "./dist/scimmy.d.ts",
   "exports": {
     ".": {
-      "import": "./dist/scimmy.js",
-      "require": "./dist/cjs/scimmy.cjs"
+      "import": {
+        "types": "./dist/scimmy.d.ts",
+        "default": "./dist/scimmy.js"
+      },
+      "require": {
+        "types": "./dist/scimmy.d.ts",
+        "default": "./dist/cjs/scimmy.cjs"
+      }
     },
     "./*": {
-      "import": "./dist/lib/*.js",
-      "require": "./dist/cjs/lib/*.js"
+      "import": {
+        "types": "./dist/scimmy.d.ts",
+        "default": "./dist/lib/*.js"
+      },
+      "require": {
+        "types": "./dist/scimmy.d.ts",
+        "default": "./dist/cjs/lib/*.js"
+      }
     }
   },
   "scripts": {
@@ -70,7 +82,7 @@
     "jsdoc": "^4.0.2",
     "minimist": "^1.2.8",
     "mocha": "^10.4.0",
-    "ostensibly-typed": "^1.0.3",
+    "ostensibly-typed": "^1.2.0",
     "rollup": "^4.25.0",
     "sinon": "^19.0.2",
     "typescript": "^5.4.5"
diff --git a/packager.js b/packager.js
index aebe36c..5fca129 100644
--- a/packager.js
+++ b/packager.js
@@ -192,14 +192,6 @@ export class Packager {
         const input = {[entry]: path.join(src, `${entry}.js`), ...chunks.reduce((chunks, chunk) => Object.assign(chunks, {[chunk]: path.join(src, `${chunk}.js`)}), {})};
         const manualChunks = chunks.reduce((chunks, chunk) => Object.assign(chunks, {[chunk]: [path.join(src, `${chunk}.js`)]}), {});
         const output = [];
-        const config = {
-            exports: "named", interop: "auto",
-            minifyInternalExports: false,
-            hoistTransitiveImports: false,
-            manualChunks, generatedCode: {
-                constBindings: true
-            }
-        };
         
         // Prepare RollupJS bundle with supplied entry points
         const bundle = await rollup.rollup({
@@ -210,8 +202,15 @@ export class Packager {
         // Construct the bundles with specified chunks in specified formats and write to destination
         for (let format of ["esm", "cjs"]) {
             const {output: results} = await bundle.write({
-                ...config, format, dir: (format === "esm" ? dest : `${dest}/${format}`),
-                entryFileNames: fileNameConfig[format], chunkFileNames: fileNameConfig[format]
+                format, exports: "named", interop: "auto",
+                dir: (format === "esm" ? dest : `${dest}/${format}`),
+                entryFileNames: fileNameConfig[format],
+                chunkFileNames: fileNameConfig[format],
+                minifyInternalExports: false,
+                hoistTransitiveImports: false,
+                manualChunks, generatedCode: {
+                    constBindings: true
+                }
             });
             
             output.push(...results.map(file => (format === "esm" ? file.fileName : `${format}/${file.fileName}`)));
@@ -234,7 +233,7 @@ export class Packager {
         
         // Prepare RollupJS with OstensiblyTyped plugin and supplied entry point
         const bundle = await rollup.rollup({
-            external, input: path.join(src, `${entry}.js`), 
+            external, input: path.join(src, `${entry}.js`),
             plugins: [generateDeclarations({moduleName: entry, defaultExport: "SCIMMY"})],
             onwarn: (warning, warn) => (warning.code !== "CIRCULAR_DEPENDENCY" ? warn(warning) : false)
         });
diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js
index a89e6b0..97458d5 100644
--- a/src/lib/messages/bulkrequest.js
+++ b/src/lib/messages/bulkrequest.js
@@ -34,16 +34,27 @@ export class BulkRequest {
     #dispatched = false;
     
     /**
-     * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest
+     * BulkRequest operation details
+     * @typedef {Object} SCIMMY.Messages.BulkRequest~BulkOpOperation
+     * @property {SCIMMY.Messages.BulkRequest~ValidBulkMethods} method - the HTTP method used for the requested operation
+     * @property {String} [bulkId] - the transient identifier of a newly created resource, unique within a bulk request and created by the client
+     * @property {String} [version] - resource version after operation has been applied
+     * @property {String} [path] - the resource's relative path to the SCIM service provider's root
+     * @property {Object} [data] - the resource data as it would appear for the corresponding single SCIM HTTP request
+     * @inner
+     */
+    
+    /**
+     * Instantiate a new SCIM BulkRequest message from the supplied operations
      * @param {Object} request - contents of the BulkRequest operation being performed
-     * @param {Object[]} request.Operations - list of SCIM-compliant bulk operations to apply
+     * @param {SCIMMY.Messages.BulkRequest~BulkOpOperation[]} request.Operations - list of SCIM-compliant bulk operations to apply
      * @param {Number} [request.failOnErrors] - number of error results to encounter before aborting any following operations
      * @param {Number} [maxOperations] - maximum number of operations supported in the request, as specified by the service provider
-     * @property {Object[]} Operations - list of operations in this BulkRequest instance
+     * @property {SCIMMY.Messages.BulkRequest~BulkOpOperation[]} Operations - list of operations in this BulkRequest instance
      * @property {Number} [failOnErrors] - number of error results a service provider should tolerate before aborting any following operations
      */
     constructor(request, maxOperations = 0) {
-        let {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {};
+        const {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {};
         
         // Make sure specified schema is valid
         if (schemas.length !== 1 || !schemas.includes(BulkRequest.#id))
@@ -69,7 +80,7 @@ export class BulkRequest {
     }
     
     /**
-     * Apply the operations specified by the supplied BulkRequest 
+     * Apply the operations specified by the supplied BulkRequest and return a new BulkResponse message
      * @param {typeof SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing bulk operations, defaults to declared resources
      * @param {*} [ctx] - any additional context information to pass to the ingress, egress, and degress handlers
      * @returns {SCIMMY.Messages.BulkResponse} a new BulkResponse Message instance with results of the requested operations 
diff --git a/src/lib/messages/bulkresponse.js b/src/lib/messages/bulkresponse.js
index 30a37ca..f99d283 100644
--- a/src/lib/messages/bulkresponse.js
+++ b/src/lib/messages/bulkresponse.js
@@ -14,15 +14,45 @@ export class BulkResponse {
      */
     static #id = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
     
+    /**
+     * BulkResponse operation response status codes
+     * @enum {200|201|204|307|308|400|401|403|404|409|412|500|501} SCIMMY.Messages.BulkResponse~ResponseStatusCodes
+     * @inner
+     */
+    
+    /**
+     * BulkResponse operation details for a given BulkRequest operation
+     * @typedef {Object} SCIMMY.Messages.BulkResponse~BulkOpResponse
+     * @property {String} [location] - canonical URI for the target resource of the operation
+     * @property {SCIMMY.Messages.BulkRequest~ValidBulkMethods} method - the HTTP method used for the requested operation
+     * @property {String} [bulkId] - the transient identifier of a newly created resource, unique within a bulk request and created by the client
+     * @property {String} [version] - resource version after operation has been applied
+     * @property {SCIMMY.Messages.BulkResponse~ResponseStatusCodes} status - the HTTP response status code for the requested operation
+     * @property {Object} [response] - the HTTP response body for the specified request operation
+     * @inner
+     */
+    
+    /**
+     * Instantiate a new outbound SCIM BulkResponse message from the results of performed operations
+     * @overload
+     * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} operations - results of performed operations
+     */
+    /**
+     * Instantiate a new inbound SCIM BulkResponse message instance from the received response
+     * @overload
+     * @param {Object} request - contents of the received BulkResponse message
+     * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request.Operations - list of SCIM-compliant bulk operation results
+     */
     /**
      * Instantiate a new SCIM BulkResponse message from the supplied Operations
-     * @param {Object|Object[]} request - contents of the BulkResponse if object, or results of performed operations if array
-     * @param {Object[]} [request.Operations] - list of applied SCIM-compliant bulk operation results, if request is an object
-     * @property {Object[]} Operations - list of BulkResponse operation results
+     * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request - results of performed operations if array
+     * @param {Object} request - contents of the received BulkResponse message if object
+     * @param {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} request.Operations - list of SCIM-compliant bulk operation results
+     * @property {SCIMMY.Messages.BulkResponse~BulkOpResponse[]} Operations - list of BulkResponse operation results
      */
     constructor(request = []) {
-        let outbound = Array.isArray(request),
-            operations = (outbound ? request : request?.Operations ?? []);
+        const outbound = Array.isArray(request);
+        const operations = (outbound ? request : request?.Operations ?? []);
         
         // Verify the BulkResponse contents are valid
         if (!outbound && Array.isArray(request?.schemas) && (!request.schemas.includes(BulkResponse.#id) || request.schemas.length > 1))
diff --git a/src/lib/messages/error.js b/src/lib/messages/error.js
index c97be35..ca2c9aa 100644
--- a/src/lib/messages/error.js
+++ b/src/lib/messages/error.js
@@ -36,11 +36,11 @@ export class ErrorResponse extends Error {
     static #id = "urn:ietf:params:scim:api:messages:2.0:Error";
     
     /**
+     * Details of the underlying cause of the error response
      * @typedef {Object} SCIMMY.Messages.ErrorResponse~CauseDetails
      * @property {SCIMMY.Messages.ErrorResponse~ValidStatusCodes} [status=500] - HTTP status code to be sent with the error
      * @property {SCIMMY.Messages.ErrorResponse~ValidScimTypes} [scimType] - the SCIM detail error keyword as per [RFC7644§3.12]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.12}
      * @property {String} [detail] - a human-readable description of what caused the error to occur
-     * @internal
      * @inner
      */
     
diff --git a/src/lib/messages/listresponse.js b/src/lib/messages/listresponse.js
index 8096d0b..be6d3ca 100644
--- a/src/lib/messages/listresponse.js
+++ b/src/lib/messages/listresponse.js
@@ -12,13 +12,19 @@ export class ListResponse {
      */
     static #id = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
     
+    /**
+     * ListResponse sort and pagination constraints
+     * @typedef {Object} SCIMMY.Messages.ListResponse~ListConstraints
+     * @property {String} [sortBy] - the attribute to sort results by, if any
+     * @property {String} [sortOrder="ascending"] - the direction to sort results in, if sortBy is specified
+     * @property {Number} [startIndex=1] - offset index that items start from
+     * @property {Number} [count=20] - maximum number of items returned in this list response
+     */
+    
     /**
      * Instantiate a new SCIM List Response Message with relevant details
      * @param {Object|SCIMMY.Types.Schema[]} request - contents of the ListResponse message, or items to include in the list response
-     * @param {Object} [params] - parameters for the list response (i.e. sort details, start index, and items per page)
-     * @param {String} [params.sortBy] - the attribute to sort results by, if any
-     * @param {String} [params.sortOrder="ascending"] - the direction to sort results in, if sortBy is specified
-     * @param {Number} [params.startIndex=1] - offset index that items start from
+     * @param {SCIMMY.Messages.ListResponse~ListConstraints} [params] - parameters for the list response (i.e. sort details, start index, and items per page)
      * @param {Number} [params.count=20] - alias property for itemsPerPage, used only if itemsPerPage is unset
      * @param {Number} [params.itemsPerPage=20] - maximum number of items returned in this list response
      * @property {Array<Object|SCIMMY.Types.Schema>} Resources - resources included in the list response
diff --git a/src/lib/resources/group.js b/src/lib/resources/group.js
index fe91118..654592c 100644
--- a/src/lib/resources/group.js
+++ b/src/lib/resources/group.js
@@ -34,6 +34,11 @@ export class Group extends Types.Resource {
         return Schemas.Group;
     }
     
+    /** @implements {SCIMMY.Types.Resource.extend<typeof SCIMMY.Resources.Group>} */
+    static extend(...args) {
+        return super.extend(...args);
+    }
+    
     /** @private */
     static #ingress = () => {
         throw new Types.Error(501, null, "Method 'ingress' not implemented by resource 'Group'");
diff --git a/src/lib/resources/user.js b/src/lib/resources/user.js
index 7539440..c862f11 100644
--- a/src/lib/resources/user.js
+++ b/src/lib/resources/user.js
@@ -34,6 +34,11 @@ export class User extends Types.Resource {
         return Schemas.User;
     }
     
+    /** @implements {SCIMMY.Types.Resource.extend<typeof SCIMMY.Resources.User>} */
+    static extend(...args) {
+        return super.extend(...args);
+    }
+    
     /** @private */
     static #ingress = () => {
         throw new Types.Error(501, null, "Method 'ingress' not implemented by resource 'User'");
diff --git a/src/lib/schemas/enterpriseuser.js b/src/lib/schemas/enterpriseuser.js
index 69d9f17..743af66 100644
--- a/src/lib/schemas/enterpriseuser.js
+++ b/src/lib/schemas/enterpriseuser.js
@@ -8,6 +8,11 @@ import Types from "../types.js";
  * *   Can be used directly, but is typically used to extend the `SCIMMY.Schemas.User` schema definition.
  */
 export class EnterpriseUser extends Types.Schema {
+    /** @type {"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"} */
+    static get id() {
+        return EnterpriseUser.#definition.id;
+    }
+    
     /** @implements {SCIMMY.Types.Schema.definition} */
     static get definition() {
         return EnterpriseUser.#definition;
diff --git a/src/lib/schemas/group.js b/src/lib/schemas/group.js
index 130197a..fbcccce 100644
--- a/src/lib/schemas/group.js
+++ b/src/lib/schemas/group.js
@@ -7,6 +7,11 @@ import Types from "../types.js";
  * *   Ensures a Group instance conforms to the Group schema set out in [RFC7643§4.2](https://datatracker.ietf.org/doc/html/rfc7643#section-4.2).
  */
 export class Group extends Types.Schema {
+    /** @type {"urn:ietf:params:scim:schemas:core:2.0:Group"} */
+    static get id() {
+        return Group.#definition.id;
+    }
+    
     /** @implements {SCIMMY.Types.Schema.definition} */
     static get definition() {
         return Group.#definition;
diff --git a/src/lib/schemas/resourcetype.js b/src/lib/schemas/resourcetype.js
index c9b7acf..5ee59c1 100644
--- a/src/lib/schemas/resourcetype.js
+++ b/src/lib/schemas/resourcetype.js
@@ -7,6 +7,11 @@ import Types from "../types.js";
  * *   Ensures a ResourceType instance conforms to the ResourceType schema set out in [RFC7643§6](https://datatracker.ietf.org/doc/html/rfc7643#section-6).
  */
 export class ResourceType extends Types.Schema {
+    /** @type {"urn:ietf:params:scim:schemas:core:2.0:ResourceType"} */
+    static get id() {
+        return ResourceType.#definition.id;
+    }
+    
     /** @implements {SCIMMY.Types.Schema.definition} */
     static get definition() {
         return ResourceType.#definition;
diff --git a/src/lib/schemas/spconfig.js b/src/lib/schemas/spconfig.js
index 9dc0276..ce6d487 100644
--- a/src/lib/schemas/spconfig.js
+++ b/src/lib/schemas/spconfig.js
@@ -7,6 +7,11 @@ import Types from "../types.js";
  * *   Ensures a ServiceProviderConfig instance conforms to the Service Provider Configuration schema set out in [RFC7643§5](https://datatracker.ietf.org/doc/html/rfc7643#section-5).
  */
 export class ServiceProviderConfig extends Types.Schema {
+    /** @type {"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"} */
+    static get id() {
+        return ServiceProviderConfig.#definition.id;
+    }
+    
     /** @implements {SCIMMY.Types.Schema.definition} */
     static get definition() {
         return ServiceProviderConfig.#definition;
@@ -14,8 +19,7 @@ export class ServiceProviderConfig extends Types.Schema {
     
     /** @private */
     static #definition = new Types.SchemaDefinition(
-        "ServiceProviderConfig", "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
-        "Schema for representing the service provider's configuration", [
+        "ServiceProviderConfig", "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", "Schema for representing the service provider's configuration", [
             new Types.Attribute("reference", "documentationUri", {mutable: false, referenceTypes: ["external"], description: "An HTTP-addressable URL pointing to the service provider's human-consumable help documentation."}),
             new Types.Attribute("complex", "patch", {required: true, mutable: false, uniqueness: false, description: "A complex type that specifies PATCH configuration options."}, [
                 new Types.Attribute("boolean", "supported", {required: true, mutable: false, description: "A Boolean value specifying whether or not the operation is supported."})
diff --git a/src/lib/schemas/user.js b/src/lib/schemas/user.js
index 505e642..7bb18db 100644
--- a/src/lib/schemas/user.js
+++ b/src/lib/schemas/user.js
@@ -7,6 +7,11 @@ import Types from "../types.js";
  * *   Ensures a User instance conforms to the User schema set out in [RFC7643§4.1](https://datatracker.ietf.org/doc/html/rfc7643#section-4.1).
  */
 export class User extends Types.Schema {
+    /** @type {"urn:ietf:params:scim:schemas:core:2.0:User"} */
+    static get id() {
+        return User.#definition.id;
+    }
+    
     /** @implements {SCIMMY.Types.Schema.definition} */
     static get definition() {
         return User.#definition;
diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js
index d0adaef..72ff0e5 100644
--- a/src/lib/types/resource.js
+++ b/src/lib/types/resource.js
@@ -6,7 +6,7 @@ import {Filter} from "./filter.js";
 /**
  * Automatically assigned attributes not required in handler return values
  * @enum {"schemas"|"meta"} SCIMMY.Types.Resource~ShadowAttributes
- * @private
+ * @ignore
  */
 
 /**
@@ -57,9 +57,12 @@ export class Resource {
     
     /**
      * Register an extension to the resource's core schema
+     * @template {typeof SCIMMY.Types.Resource<S>} R
+     * @template {SCIMMY.Types.Schema} [S=*] - type of schema instance that will be passed to handlers
      * @param {typeof SCIMMY.Types.Schema} extension - the schema extension to register
      * @param {Boolean} [required] - whether the extension is required
-     * @returns {typeof SCIMMY.Types.Resource} this resource type implementation for chaining
+     * @returns {R} this resource type implementation for chaining
+     * @abstract
      */
     static extend(extension, required) {
         this.schema.extend(extension, required);
@@ -69,7 +72,7 @@ export class Resource {
     
     /**
      * Handler for ingress of a resource
-     * @template {SCIMMY.Types.Resource<any>} R - type of resource instance performing ingress
+     * @template {SCIMMY.Types.Resource<S>} R - type of resource instance performing ingress
      * @template {SCIMMY.Types.Schema} S - type of schema instance that will be passed to handler
      * @template {Record<String, *>} [V=Omit<Awaited<S>, Resource.ShadowAttributes>] - shape of return value
      * @callback SCIMMY.Types.Resource~IngressHandler
@@ -112,7 +115,8 @@ export class Resource {
      * Sets the method to be called to consume a resource on create
      * @template {typeof SCIMMY.Types.Resource<any>} R
      * @template {SCIMMY.Types.Schema} S
-     * @param {SCIMMY.Types.Resource~IngressHandler<InstanceType<R>, S>} handler - function to invoke to consume a resource on create
+     * @typeParam {S} [V=S]
+     * @param {SCIMMY.Types.Resource~IngressHandler<InstanceType<R>, V>} handler - function to invoke to consume a resource on create
      * @returns {R} this resource type class for chaining
      * @abstract
      */
@@ -122,7 +126,7 @@ export class Resource {
     
     /**
      * Handler for egress of a resource
-     * @template {SCIMMY.Types.Resource<any>} R - type of resource instance performing egress
+     * @template {SCIMMY.Types.Resource<S>} R - type of resource instance performing egress
      * @template {SCIMMY.Types.Schema} S - type of schema instance that will be passed to handler
      * @template {Record<String, *>} [V=Omit<Awaited<S>, Resource.ShadowAttributes>] - shape of return value
      * @callback SCIMMY.Types.Resource~EgressHandler
@@ -161,7 +165,8 @@ export class Resource {
      * Sets the method to be called to retrieve a resource on read
      * @template {typeof SCIMMY.Types.Resource<any>} R
      * @template {SCIMMY.Types.Schema} S
-     * @param {SCIMMY.Types.Resource~EgressHandler<InstanceType<R>, S>} handler - function to invoke to retrieve a resource on read
+     * @typeParam {S} [V=S]
+     * @param {SCIMMY.Types.Resource~EgressHandler<InstanceType<R>, V>} handler - function to invoke to retrieve a resource on read
      * @returns {R} this resource type class for chaining
      * @abstract
      */
@@ -175,6 +180,7 @@ export class Resource {
      * @callback SCIMMY.Types.Resource~DegressHandler
      * @param {R} resource - the resource performing the degress
      * @param {*} [ctx] - external context in which the handler has been called
+     * @returns {void|Promise<void>}
      * @example
      * // Handle a request to delete a specific resource
      * async function degress(resource, ctx) {
@@ -256,7 +262,7 @@ export class Resource {
      * @property {String} [id] - ID of the resource instance being targeted
      * @property {SCIMMY.Types.Filter} [filter] - filter parsed from the supplied config
      * @property {SCIMMY.Types.Filter} [attributes] - attributes or excluded attributes parsed from the supplied config
-     * @property {Object} [constraints] - sort and pagination properties parsed from the supplied config
+     * @property {SCIMMY.Messages.ListResponse~ListConstraints} [constraints] - sort and pagination properties parsed from the supplied config
      * @property {String} [constraints.sortBy] - the attribute retrieved resources should be sorted by
      * @property {String} [constraints.sortOrder] - the direction retrieved resources should be sorted in
      * @property {Number} [constraints.startIndex] - offset index that retrieved resources should start from
diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js
index de55fea..194889d 100644
--- a/src/lib/types/schema.js
+++ b/src/lib/types/schema.js
@@ -24,6 +24,21 @@ const defineToJSONProperty = (target, definition, resource) => Object.defineProp
  */
 const hasActualValues = (target) => (Object.values(target).some((v) => typeof v === "object" ? hasActualValues(v) : v !== undefined));
 
+/**
+ * Automatically assigned attributes not required in schema extension values
+ * @enum {"id"|"schemas"|"meta"} SCIMMY.Types.Schema~ShadowAttributes
+ * @ignore
+ */
+
+/**
+ * A schema instance type with an added schema extension
+ * @typedef {V} SCIMMY.Types.Schema~Extended
+ * @template {SCIMMY.Types.Schema} S
+ * @template {typeof SCIMMY.Types.Schema} E
+ * @template {S} [V=(S & {[K in keyof Pick<E, "id"> as `${E[K]}`]?: Omit<InstanceType<E>, Schema.ShadowAttributes>})]
+ * @ignore
+ */
+
 /**
  * SCIM Schema Type
  * @alias SCIMMY.Types.Schema
@@ -32,13 +47,22 @@ const hasActualValues = (target) => (Object.values(target).some((v) => typeof v
  * *   Once instantiated, any modifications will also be validated against the attached schema definition's matching attribute configuration (e.g. for mutability or canonical values).
  */
 export class Schema {
+    /**
+     * SCIM schema URN namespace
+     * @type {String}
+     * @abstract
+     */
+    static get id() {
+        throw new TypeError("Method 'get' for static property 'id' must be implemented by subclass");
+    }
+    
     /**
      * Retrieves a schema's definition instance
      * @type {SCIMMY.Types.SchemaDefinition}
      * @abstract
      */
     static get definition() {
-        throw new TypeError("Method 'get' for property 'definition' must be implemented by subclass");
+        throw new TypeError("Method 'get' for static property 'definition' must be implemented by subclass");
     }
     
     /**
@@ -64,7 +88,7 @@ export class Schema {
     
     /**
      * Remove an attribute, schema extension, or subAttribute from the schema's definition
-     * @param {SCIMMY.Types.Schema|String|SCIMMY.Types.Attribute|Array<String|SCIMMY.Types.Attribute>} attributes - the child attributes to remove from the schema definition
+     * @param {SCIMMY.Types.Schema|String|SCIMMY.Types.Attribute|Array<String>|Array<SCIMMY.Types.Attribute>} attributes - the child attributes to remove from the schema definition
      */
     static truncate(attributes) {
         this.definition.truncate(attributes?.prototype instanceof Schema ? attributes.definition : attributes);


Test patch:
diff --git a/test/hooks/resources.js b/test/hooks/resources.js
index e8beef1..5c92928 100644
--- a/test/hooks/resources.js
+++ b/test/hooks/resources.js
@@ -123,19 +123,24 @@ export default class ResourcesHooks {
     extend = (supported = true) => (() => {
         const TargetResource = this.#target;
         
+        it("should be overridden", () => {
+            assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"),
+                "Static method 'extend' was not overridden");
+            assert.ok(typeof TargetResource.extend === "function",
+                "Static method 'extend' was not a function");
+        });
+        
         if (supported) {
-            it("should not be overridden", () => {
-                assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"),
-                    "Static method 'extend' unexpectedly overridden by resource");
+            const sandbox = this.#sandbox;
+            
+            it("should call through to the 'extend' method of the super class", () => {
+                const stub = sandbox.stub(Resource, "extend");
+                
+                TargetResource.extend();
+                assert.ok(stub.calledOnce, "Static method 'extend' did not call through to super class");
+                stub.restore();
             });
         } else {
-            it("should be overridden", () => {
-                assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"),
-                    "Static method 'extend' was not overridden");
-                assert.ok(typeof TargetResource.extend === "function",
-                    "Static method 'extend' was not a function");
-            });
-            
             it("should throw an 'unsupported' error", () => {
                 assert.throws(() => TargetResource.extend(),
                     {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`},
diff --git a/test/hooks/schemas.js b/test/hooks/schemas.js
index 918d3fc..f407b3b 100644
--- a/test/hooks/schemas.js
+++ b/test/hooks/schemas.js
@@ -220,6 +220,25 @@ export default class ResourcesHooks {
         });
     });
     
+    id = () => (() => {
+        const TargetSchema = this.#target;
+        
+        it("should be defined", () => {
+            assert.ok("id" in TargetSchema,
+                "Static member 'id' not defined");
+        });
+        
+        it("should be a string", () => {
+            assert.ok(typeof TargetSchema.id === "string",
+                "Static member 'id' was not a string");
+        });
+        
+        it("should match ID from definition instance", async () => {
+            assert.strictEqual(TargetSchema.id, TargetSchema.definition.id,
+                "Static member 'id' did not match 'id' from definition instance");
+        });
+    });
+    
     definition = () => (() => {
         const TargetSchema = this.#target;
         const fixtures = this.#fixtures;
diff --git a/test/lib/schemas/enterpriseuser.js b/test/lib/schemas/enterpriseuser.js
index 8798d32..1f437d3 100644
--- a/test/lib/schemas/enterpriseuser.js
+++ b/test/lib/schemas/enterpriseuser.js
@@ -11,6 +11,7 @@ const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8
 describe("SCIMMY.Schemas.EnterpriseUser", () => {
     const hooks = new SchemasHooks(EnterpriseUser, fixtures);
     
+    describe(".id", hooks.id());
     describe(".definition", hooks.definition());
     describe("@constructor", hooks.construct());
 });
\ No newline at end of file
diff --git a/test/lib/schemas/group.js b/test/lib/schemas/group.js
index d845e31..7dcd981 100644
--- a/test/lib/schemas/group.js
+++ b/test/lib/schemas/group.js
@@ -11,6 +11,7 @@ const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((
 describe("SCIMMY.Schemas.Group", () => {
     const hooks = new SchemasHooks(Group, fixtures);
     
+    describe(".id", hooks.id());
     describe(".definition", hooks.definition());
     describe("@constructor", hooks.construct());
 });
\ No newline at end of file
diff --git a/test/lib/schemas/resourcetype.js b/test/lib/schemas/resourcetype.js
index 67450ff..9faa625 100644
--- a/test/lib/schemas/resourcetype.js
+++ b/test/lib/schemas/resourcetype.js
@@ -11,6 +11,7 @@ const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8")
 describe("SCIMMY.Schemas.ResourceType", () => {
     const hooks = new SchemasHooks(ResourceType, fixtures);
     
+    describe(".id", hooks.id());
     describe(".definition", hooks.definition());
     describe("@constructor", hooks.construct());
 });
\ No newline at end of file
diff --git a/test/lib/schemas/spconfig.js b/test/lib/schemas/spconfig.js
index a834727..96a0e49 100644
--- a/test/lib/schemas/spconfig.js
+++ b/test/lib/schemas/spconfig.js
@@ -11,6 +11,7 @@ const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").the
 describe("SCIMMY.Schemas.ServiceProviderConfig", () => {
     const hooks = new SchemasHooks(ServiceProviderConfig, fixtures);
     
+    describe(".id", hooks.id());
     describe(".definition", hooks.definition());
     describe("@constructor", hooks.construct());
 });
\ No newline at end of file
diff --git a/test/lib/schemas/user.js b/test/lib/schemas/user.js
index 3e117d3..bc915b4 100644
--- a/test/lib/schemas/user.js
+++ b/test/lib/schemas/user.js
@@ -11,6 +11,7 @@ const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f
 describe("SCIMMY.Schemas.User", () => {
     const hooks = new SchemasHooks(User, fixtures);
     
+    describe(".id", hooks.id());
     describe(".definition", hooks.definition());
     describe("@constructor", hooks.construct());
 });
\ No newline at end of file
diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js
index ff3b6d8..1562165 100644
--- a/test/lib/types/schema.js
+++ b/test/lib/types/schema.js
@@ -11,6 +11,19 @@ const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(imp
 const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f));
 
 describe("SCIMMY.Types.Schema", () => {
+    describe(".id", () => {
+        it("should be defined", () => {
+            assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "id").get === "function",
+                "Static member 'id' was not defined");
+        });
+        
+        it("should be abstract", () => {
+            assert.throws(() => Schema.id,
+                {name: "TypeError", message: "Method 'get' for static property 'id' must be implemented by subclass"},
+                "Static member 'id' was not abstract");
+        });
+    });
+    
     describe(".definition", () => {
         it("should be defined", () => {
             assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "definition").get === "function",
@@ -19,7 +32,7 @@ describe("SCIMMY.Types.Schema", () => {
         
         it("should be abstract", () => {
             assert.throws(() => Schema.definition,
-                {name: "TypeError", message: "Method 'get' for property 'definition' must be implemented by subclass"},
+                {name: "TypeError", message: "Method 'get' for static property 'definition' must be implemented by subclass"},
                 "Static member 'definition' was not abstract");
         });
     });