From bbc914340499793249137e6f4b017a34c6a3e827 Mon Sep 17 00:00:00 2001
From: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Date: Fri, 19 Nov 2021 18:45:34 -0500
Subject: [PATCH] Convert models to TS

---
 js/src/@types/global.d.ts                     |  11 +
 js/src/admin/AdminApplication.ts              |   4 +-
 js/src/admin/components/UserListPage.tsx      |  24 +-
 js/src/common/Application.tsx                 |  21 +-
 js/src/common/Model.js                        | 323 ---------------
 js/src/common/Model.ts                        | 377 ++++++++++++++++++
 js/src/common/Store.js                        | 171 --------
 js/src/common/Store.ts                        | 239 +++++++++++
 js/src/common/helpers/username.tsx            |   2 +-
 js/src/common/models/Discussion.js            | 108 -----
 js/src/common/models/Discussion.ts            | 146 +++++++
 js/src/common/models/{Forum.js => Forum.ts}   |   0
 js/src/common/models/Group.js                 |  17 -
 js/src/common/models/Group.ts                 |  25 ++
 js/src/common/models/Notification.js          |  15 -
 js/src/common/models/Notification.ts          |  28 ++
 js/src/common/models/Post.js                  |  31 --
 js/src/common/models/Post.ts                  |  67 ++++
 js/src/common/models/User.js                  | 124 ------
 js/src/common/models/User.ts                  | 164 ++++++++
 js/src/common/states/PaginatedListState.ts    |  48 ++-
 .../common/utils/{computed.js => computed.ts} |  17 +-
 js/src/forum/components/DiscussionPage.tsx    |  16 +-
 .../components/DiscussionsSearchSource.tsx    |   2 +-
 js/src/forum/components/Search.tsx            |   2 +-
 js/src/forum/components/UsersSearchSource.tsx |   4 +-
 js/src/forum/states/DiscussionListState.ts    |  20 +-
 27 files changed, 1138 insertions(+), 868 deletions(-)
 delete mode 100644 js/src/common/Model.js
 create mode 100644 js/src/common/Model.ts
 delete mode 100644 js/src/common/Store.js
 create mode 100644 js/src/common/Store.ts
 delete mode 100644 js/src/common/models/Discussion.js
 create mode 100644 js/src/common/models/Discussion.ts
 rename js/src/common/models/{Forum.js => Forum.ts} (100%)
 delete mode 100644 js/src/common/models/Group.js
 create mode 100644 js/src/common/models/Group.ts
 delete mode 100644 js/src/common/models/Notification.js
 create mode 100644 js/src/common/models/Notification.ts
 delete mode 100644 js/src/common/models/Post.js
 create mode 100644 js/src/common/models/Post.ts
 delete mode 100644 js/src/common/models/User.js
 create mode 100644 js/src/common/models/User.ts
 rename js/src/common/utils/{computed.js => computed.ts} (60%)

diff --git a/js/src/@types/global.d.ts b/js/src/@types/global.d.ts
index 76b9f3e5a..e3ff2fe27 100644
--- a/js/src/@types/global.d.ts
+++ b/js/src/@types/global.d.ts
@@ -46,6 +46,17 @@ declare const app: never;
 declare const m: import('mithril').Static;
 declare const dayjs: typeof import('dayjs');
 
+/**
+ * From https://github.com/lokesh/color-thief/issues/188
+ */
+declare module 'color-thief-browser' {
+  type Color = [number, number, number];
+  export default class ColorThief {
+    getColor: (img: HTMLImageElement | null) => Color;
+    getPalette: (img: HTMLImageElement | null) => Color[];
+  }
+}
+
 type ESModule = { __esModule: true; [key: string]: unknown };
 
 /**
diff --git a/js/src/admin/AdminApplication.ts b/js/src/admin/AdminApplication.ts
index a1c9ffc8c..0b24f1210 100644
--- a/js/src/admin/AdminApplication.ts
+++ b/js/src/admin/AdminApplication.ts
@@ -44,9 +44,9 @@ export default class AdminApplication extends Application {
   history = {
     canGoBack: () => true,
     getPrevious: () => {},
-    backUrl: () => this.forum.attribute('baseUrl'),
+    backUrl: () => this.forum.attribute<string>('baseUrl'),
     back: function () {
-      window.location = this.backUrl();
+      window.location.assign(this.backUrl());
     },
   };
 
diff --git a/js/src/admin/components/UserListPage.tsx b/js/src/admin/components/UserListPage.tsx
index 8de993a49..675104aea 100644
--- a/js/src/admin/components/UserListPage.tsx
+++ b/js/src/admin/components/UserListPage.tsx
@@ -1,3 +1,5 @@
+import type Mithril from 'mithril';
+
 import app from '../../admin/app';
 
 import EditUserModal from '../../common/components/EditUserModal';
@@ -14,7 +16,6 @@ import classList from '../../common/utils/classList';
 import extractText from '../../common/utils/extractText';
 
 import AdminPage from './AdminPage';
-import Mithril from 'mithril';
 
 type ColumnData = {
   /**
@@ -24,20 +25,9 @@ type ColumnData = {
   /**
    * Component(s) to show for this column.
    */
-  content: (user: User) => JSX.Element;
+  content: (user: User) => Mithril.Children;
 };
 
-type ApiPayload = {
-  data: Record<string, unknown>[];
-  included: Record<string, unknown>[];
-  links: {
-    first: string;
-    next?: string;
-  };
-};
-
-type UsersApiResponse = User[] & { payload: ApiPayload };
-
 /**
  * Admin page which displays a paginated list of all users on the forum.
  */
@@ -185,7 +175,7 @@ export default class UserListPage extends AdminPage {
       'id',
       {
         name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
-        content: (user: User) => user.id(),
+        content: (user: User) => user.id() ?? '',
       },
       100
     );
@@ -348,15 +338,15 @@ export default class UserListPage extends AdminPage {
     if (pageNumber < 0) pageNumber = 0;
 
     app.store
-      .find('users', {
+      .find<User[]>('users', {
         page: {
           limit: this.numPerPage,
           offset: pageNumber * this.numPerPage,
         },
       })
-      .then((apiData: UsersApiResponse) => {
+      .then((apiData) => {
         // Next link won't be present if there's no more data
-        this.moreData = !!apiData.payload.links.next;
+        this.moreData = !!apiData.payload?.links?.next;
 
         let data = apiData;
 
diff --git a/js/src/common/Application.tsx b/js/src/common/Application.tsx
index a8ffbb083..835a75363 100644
--- a/js/src/common/Application.tsx
+++ b/js/src/common/Application.tsx
@@ -6,7 +6,7 @@ import ModalManager from './components/ModalManager';
 import AlertManager from './components/AlertManager';
 import RequestErrorModal from './components/RequestErrorModal';
 import Translator from './Translator';
-import Store from './Store';
+import Store, { ApiPayload, ApiResponse, ApiResponsePlural, ApiResponseSingle, payloadIsPlural } from './Store';
 import Session from './Session';
 import extract from './utils/extract';
 import Drawer from './utils/Drawer';
@@ -31,6 +31,7 @@ import type DefaultResolver from './resolvers/DefaultResolver';
 import type Mithril from 'mithril';
 import type Component from './Component';
 import type { ComponentAttrs } from './Component';
+import Model, { SavedModelData } from './Model';
 
 export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
 
@@ -210,10 +211,10 @@ export default class Application {
   drawer!: Drawer;
 
   data!: {
-    apiDocument: Record<string, unknown> | null;
+    apiDocument: ApiPayload | null;
     locale: string;
     locales: Record<string, string>;
-    resources: Record<string, unknown>[];
+    resources: SavedModelData[];
     session: { userId: number; csrfToken: string };
     [key: string]: unknown;
   };
@@ -255,9 +256,9 @@ export default class Application {
 
     this.store.pushPayload({ data: this.data.resources });
 
-    this.forum = this.store.getById('forums', 1);
+    this.forum = this.store.getById('forums', '1')!;
 
-    this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
+    this.session = new Session(this.store.getById('users', String(this.data.session.userId)), this.data.session.csrfToken);
 
     this.mount();
 
@@ -317,10 +318,14 @@ export default class Application {
   /**
    * Get the API response document that has been preloaded into the application.
    */
-  preloadedApiDocument(): Record<string, unknown> | null {
+  preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | null;
+  preloadedApiDocument<Ms extends Model[]>(): ApiResponsePlural<Ms[number]> | null;
+  preloadedApiDocument<M extends Model | Model[]>(): ApiResponse<FlatArray<M, 1>> | null {
     // If the URL has changed, the preloaded Api document is invalid.
     if (this.data.apiDocument && window.location.href === this.initialRoute) {
-      const results = this.store.pushPayload(this.data.apiDocument);
+      const results = payloadIsPlural(this.data.apiDocument)
+        ? this.store.pushPayload<FlatArray<M, 1>[]>(this.data.apiDocument)
+        : this.store.pushPayload<FlatArray<M, 1>>(this.data.apiDocument);
 
       this.data.apiDocument = null;
 
@@ -450,7 +455,7 @@ export default class Application {
    * @param options
    * @return {Promise}
    */
-  request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
+  request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType> {
     const options = this.transformRequestOptions(originalOptions);
 
     if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
diff --git a/js/src/common/Model.js b/js/src/common/Model.js
deleted file mode 100644
index 24937f214..000000000
--- a/js/src/common/Model.js
+++ /dev/null
@@ -1,323 +0,0 @@
-import app from '../common/app';
-
-/**
- * The `Model` class represents a local data resource. It provides methods to
- * persist changes via the API.
- *
- * @abstract
- */
-export default class Model {
-  /**
-   * @param {Object} data A resource object from the API.
-   * @param {Store} store The data store that this model should be persisted to.
-   * @public
-   */
-  constructor(data = {}, store = null) {
-    /**
-     * The resource object from the API.
-     *
-     * @type {Object}
-     * @public
-     */
-    this.data = data;
-
-    /**
-     * The time at which the model's data was last updated. Watching the value
-     * of this property is a fast way to retain/cache a subtree if data hasn't
-     * changed.
-     *
-     * @type {Date}
-     * @public
-     */
-    this.freshness = new Date();
-
-    /**
-     * Whether or not the resource exists on the server.
-     *
-     * @type {Boolean}
-     * @public
-     */
-    this.exists = false;
-
-    /**
-     * The data store that this resource should be persisted to.
-     *
-     * @type {Store}
-     * @protected
-     */
-    this.store = store;
-  }
-
-  /**
-   * Get the model's ID.
-   *
-   * @return {Integer}
-   * @public
-   * @final
-   */
-  id() {
-    return this.data.id;
-  }
-
-  /**
-   * Get one of the model's attributes.
-   *
-   * @param {String} attribute
-   * @return {*}
-   * @public
-   * @final
-   */
-  attribute(attribute) {
-    return this.data.attributes[attribute];
-  }
-
-  /**
-   * Merge new data into this model locally.
-   *
-   * @param {Object} data A resource object to merge into this model
-   * @public
-   */
-  pushData(data) {
-    // Since most of the top-level items in a resource object are objects
-    // (e.g. relationships, attributes), we'll need to check and perform the
-    // merge at the second level if that's the case.
-    for (const key in data) {
-      if (typeof data[key] === 'object') {
-        this.data[key] = this.data[key] || {};
-
-        // For every item in a second-level object, we want to check if we've
-        // been handed a Model instance. If so, we will convert it to a
-        // relationship data object.
-        for (const innerKey in data[key]) {
-          if (data[key][innerKey] instanceof Model) {
-            data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
-          }
-          this.data[key][innerKey] = data[key][innerKey];
-        }
-      } else {
-        this.data[key] = data[key];
-      }
-    }
-
-    // Now that we've updated the data, we can say that the model is fresh.
-    // This is an easy way to invalidate retained subtrees etc.
-    this.freshness = new Date();
-  }
-
-  /**
-   * Merge new attributes into this model locally.
-   *
-   * @param {Object} attributes The attributes to merge.
-   * @public
-   */
-  pushAttributes(attributes) {
-    this.pushData({ attributes });
-  }
-
-  /**
-   * Merge new attributes into this model, both locally and with persistence.
-   *
-   * @param {Object} attributes The attributes to save. If a 'relationships' key
-   *     exists, it will be extracted and relationships will also be saved.
-   * @param {Object} [options]
-   * @return {Promise}
-   * @public
-   */
-  save(attributes, options = {}) {
-    const data = {
-      type: this.data.type,
-      id: this.data.id,
-      attributes,
-    };
-
-    // If a 'relationships' key exists, extract it from the attributes hash and
-    // set it on the top-level data object instead. We will be sending this data
-    // object to the API for persistence.
-    if (attributes.relationships) {
-      data.relationships = {};
-
-      for (const key in attributes.relationships) {
-        const model = attributes.relationships[key];
-
-        data.relationships[key] = {
-          data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
-        };
-      }
-
-      delete attributes.relationships;
-    }
-
-    // Before we update the model's data, we should make a copy of the model's
-    // old data so that we can revert back to it if something goes awry during
-    // persistence.
-    const oldData = this.copyData();
-
-    this.pushData(data);
-
-    const request = { data };
-    if (options.meta) request.meta = options.meta;
-
-    return app
-      .request(
-        Object.assign(
-          {
-            method: this.exists ? 'PATCH' : 'POST',
-            url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
-            body: request,
-          },
-          options
-        )
-      )
-      .then(
-        // If everything went well, we'll make sure the store knows that this
-        // model exists now (if it didn't already), and we'll push the data that
-        // the API returned into the store.
-        (payload) => {
-          this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
-          this.store.data[payload.data.type][payload.data.id] = this;
-          return this.store.pushPayload(payload);
-        },
-
-        // If something went wrong, though... good thing we backed up our model's
-        // old data! We'll revert to that and let others handle the error.
-        (response) => {
-          this.pushData(oldData);
-          m.redraw();
-          throw response;
-        }
-      );
-  }
-
-  /**
-   * Send a request to delete the resource.
-   *
-   * @param {Object} body Data to send along with the DELETE request.
-   * @param {Object} [options]
-   * @return {Promise}
-   * @public
-   */
-  delete(body, options = {}) {
-    if (!this.exists) return Promise.resolve();
-
-    return app
-      .request(
-        Object.assign(
-          {
-            method: 'DELETE',
-            url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
-            body,
-          },
-          options
-        )
-      )
-      .then(() => {
-        this.exists = false;
-        this.store.remove(this);
-      });
-  }
-
-  /**
-   * Construct a path to the API endpoint for this resource.
-   *
-   * @return {String}
-   * @protected
-   */
-  apiEndpoint() {
-    return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
-  }
-
-  copyData() {
-    return JSON.parse(JSON.stringify(this.data));
-  }
-
-  /**
-   * Generate a function which returns the value of the given attribute.
-   *
-   * @param {String} name
-   * @param {function} [transform] A function to transform the attribute value
-   * @return {*}
-   * @public
-   */
-  static attribute(name, transform) {
-    return function () {
-      const value = this.data.attributes && this.data.attributes[name];
-
-      return transform ? transform(value) : value;
-    };
-  }
-
-  /**
-   * Generate a function which returns the value of the given has-one
-   * relationship.
-   *
-   * @param {String} name
-   * @return {Model|Boolean|undefined} false if no information about the
-   *     relationship exists; undefined if the relationship exists but the model
-   *     has not been loaded; or the model if it has been loaded.
-   * @public
-   */
-  static hasOne(name) {
-    return function () {
-      if (this.data.relationships) {
-        const relationship = this.data.relationships[name];
-
-        if (relationship) {
-          return app.store.getById(relationship.data.type, relationship.data.id);
-        }
-      }
-
-      return false;
-    };
-  }
-
-  /**
-   * Generate a function which returns the value of the given has-many
-   * relationship.
-   *
-   * @param {String} name
-   * @return {Array|Boolean} false if no information about the relationship
-   *     exists; an array if it does, containing models if they have been
-   *     loaded, and undefined for those that have not.
-   * @public
-   */
-  static hasMany(name) {
-    return function () {
-      if (this.data.relationships) {
-        const relationship = this.data.relationships[name];
-
-        if (relationship) {
-          return relationship.data.map((data) => app.store.getById(data.type, data.id));
-        }
-      }
-
-      return false;
-    };
-  }
-
-  /**
-   * Transform the given value into a Date object.
-   *
-   * @param {String} value
-   * @return {Date|null}
-   * @public
-   */
-  static transformDate(value) {
-    return value ? new Date(value) : null;
-  }
-
-  /**
-   * Get a resource identifier object for the given model.
-   *
-   * @param {Model} model
-   * @return {Object}
-   * @protected
-   */
-  static getIdentifier(model) {
-    if (!model) return model;
-
-    return {
-      type: model.data.type,
-      id: model.data.id,
-    };
-  }
-}
diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts
new file mode 100644
index 000000000..b38dd595a
--- /dev/null
+++ b/js/src/common/Model.ts
@@ -0,0 +1,377 @@
+import app from '../common/app';
+import { FlarumRequestOptions } from './Application';
+import Store, { ApiPayloadSingle, ApiResponseSingle } from './Store';
+
+interface ModelIdentifier {
+  type: string;
+  id: string;
+}
+
+interface ModelAttributes {
+  [key: string]: unknown;
+}
+
+interface ModelRelationships {
+  [relationship: string]: {
+    data: ModelIdentifier | ModelIdentifier[];
+  };
+}
+
+export interface UnsavedModelData {
+  type?: string;
+  attributes?: ModelAttributes;
+  relationships?: ModelRelationships;
+}
+
+export interface SavedModelData {
+  type: string;
+  id: string;
+  attributes?: ModelAttributes;
+  relationships?: ModelRelationships;
+}
+
+export type ModelData = UnsavedModelData | SavedModelData;
+
+interface SaveRelationships {
+  [relationship: string]: Model | Model[];
+}
+
+interface SaveAttributes {
+  [key: string]: unknown;
+  relationships?: SaveRelationships;
+}
+
+/**
+ * The `Model` class represents a local data resource. It provides methods to
+ * persist changes via the API.
+ */
+export default abstract class Model {
+  /**
+   * The resource object from the API.
+   */
+  data: ModelData = {};
+
+  /**
+   * The time at which the model's data was last updated. Watching the value
+   * of this property is a fast way to retain/cache a subtree if data hasn't
+   * changed.
+   */
+  freshness: Date = new Date();
+
+  /**
+   * Whether or not the resource exists on the server.
+   */
+  exists: boolean = false;
+
+  /**
+   * The data store that this resource should be persisted to.
+   */
+  protected store: Store | null;
+
+  /**
+   * @param data A resource object from the API.
+   * @param store The data store that this model should be persisted to.
+   * @public
+   */
+  constructor(data: ModelData = {}, store = null) {
+    this.data = data;
+    this.store = store;
+  }
+
+  /**
+   * Get the model's ID.
+   *
+   * @final
+   */
+  id(): string | undefined {
+    return 'id' in this.data ? this.data.id : undefined;
+  }
+
+  /**
+   * Get one of the model's attributes.
+   *
+   * @final
+   */
+  attribute<T = unknown>(attribute: string): T {
+    return this.data?.attributes?.[attribute] as T;
+  }
+
+  /**
+   * Merge new data into this model locally.
+   *
+   * @param data A resource object to merge into this model
+   */
+  pushData(data: ModelData | { relationships?: SaveRelationships }): this {
+    if ('id' in data) {
+      (this.data as SavedModelData).id = data.id;
+    }
+
+    if ('type' in data) {
+      this.data.type = data.type;
+    }
+
+    if ('attributes' in data) {
+      Object.assign(this.data.attributes, data.attributes);
+    }
+
+    if ('relationships' in data) {
+      const relationships = this.data.relationships ?? {};
+
+      // For every relationship field, we need to check if we've
+      // been handed a Model instance. If so, we will convert it to a
+      // relationship data object.
+      for (const r in data.relationships) {
+        const relationship = data.relationships[r];
+
+        let identifier: ModelRelationships[string];
+        if (relationship instanceof Model) {
+          identifier = { data: Model.getIdentifier(relationship) };
+        } else if (relationship instanceof Array) {
+          identifier = { data: relationship.map(Model.getIdentifier) };
+        } else {
+          identifier = relationship;
+        }
+
+        data.relationships[r] = identifier;
+        relationships[r] = identifier;
+      }
+
+      this.data.relationships = relationships;
+    }
+
+    // Now that we've updated the data, we can say that the model is fresh.
+    // This is an easy way to invalidate retained subtrees etc.
+    this.freshness = new Date();
+
+    return this;
+  }
+
+  /**
+   * Merge new attributes into this model locally.
+   *
+   * @param attributes The attributes to merge.
+   * @public
+   */
+  pushAttributes(attributes: ModelAttributes) {
+    this.pushData({ attributes });
+  }
+
+  /**
+   * Merge new attributes into this model, both locally and with persistence.
+   *
+   * @param attributes The attributes to save. If a 'relationships' key
+   *     exists, it will be extracted and relationships will also be saved.
+   * @public
+   */
+  save(
+    attributes: SaveAttributes,
+    options: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & { meta?: any } = {}
+  ): Promise<ApiResponseSingle<this>> {
+    const data: ModelData & { id?: string } = {
+      type: this.data.type,
+      attributes,
+    };
+
+    if ('id' in this.data) {
+      data.id = this.data.id;
+    }
+
+    // If a 'relationships' key exists, extract it from the attributes hash and
+    // set it on the top-level data object instead. We will be sending this data
+    // object to the API for persistence.
+    if (attributes.relationships) {
+      data.relationships = {};
+
+      for (const key in attributes.relationships) {
+        const model = attributes.relationships[key];
+
+        data.relationships[key] = {
+          data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
+        };
+      }
+
+      delete attributes.relationships;
+    }
+
+    // Before we update the model's data, we should make a copy of the model's
+    // old data so that we can revert back to it if something goes awry during
+    // persistence.
+    const oldData = this.copyData();
+
+    this.pushData(data);
+
+    const request = {
+      data,
+      meta: options.meta || undefined,
+    };
+
+    return app
+      .request<ApiPayloadSingle>(
+        Object.assign(
+          {
+            method: this.exists ? 'PATCH' : 'POST',
+            url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
+            body: request,
+          },
+          options
+        )
+      )
+      .then(
+        // If everything went well, we'll make sure the store knows that this
+        // model exists now (if it didn't already), and we'll push the data that
+        // the API returned into the store.
+        (payload) => {
+          if (!this.store) {
+            throw new Error('Model has no store');
+          }
+
+          return this.store.pushPayload<this>(payload);
+        },
+
+        // If something went wrong, though... good thing we backed up our model's
+        // old data! We'll revert to that and let others handle the error.
+        (err: Error) => {
+          this.pushData(oldData);
+          m.redraw();
+          throw err;
+        }
+      );
+  }
+
+  /**
+   * Send a request to delete the resource.
+   *
+   * @param body Data to send along with the DELETE request.
+   */
+  delete(body: FlarumRequestOptions<void>['body'] = {}, options: Omit<FlarumRequestOptions<void>, 'url'> = {}): Promise<void> {
+    if (!this.exists) return Promise.resolve();
+
+    return app
+      .request(
+        Object.assign(
+          {
+            method: 'DELETE',
+            url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
+            body,
+          },
+          options
+        )
+      )
+      .then(() => {
+        this.exists = false;
+
+        if (this.store) {
+          this.store.remove(this);
+        } else {
+          throw new Error('Tried to delete a model without a store!');
+        }
+      });
+  }
+
+  /**
+   * Construct a path to the API endpoint for this resource.
+   */
+  protected apiEndpoint(): string {
+    return '/' + this.data.type + ('id' in this.data ? '/' + this.data.id : '');
+  }
+
+  protected copyData(): ModelData {
+    return JSON.parse(JSON.stringify(this.data));
+  }
+
+  protected rawRelationship<M extends Model>(relationship: string): undefined | ModelIdentifier;
+  protected rawRelationship<M extends Model[]>(relationship: string): undefined | ModelIdentifier[];
+  protected rawRelationship<_M extends Model | Model[]>(relationship: string): undefined | ModelIdentifier | ModelIdentifier[] {
+    return this.data.relationships?.[relationship]?.data;
+  }
+
+  /**
+   * Generate a function which returns the value of the given attribute.
+   *
+   * @param transform A function to transform the attribute value
+   */
+  static attribute<T>(name: string): () => T;
+  static attribute<T, O = unknown>(name: string, transform: (attr: O) => T): () => T;
+  static attribute<T, O = unknown>(name: string, transform?: (attr: O) => T): () => T {
+    return function (this: Model) {
+      if (transform) {
+        return transform(this.attribute(name));
+      }
+
+      return this.attribute(name);
+    };
+  }
+
+  /**
+   * Generate a function which returns the value of the given has-one
+   * relationship.
+   *
+   * @return false if no information about the
+   *     relationship exists; undefined if the relationship exists but the model
+   *     has not been loaded; or the model if it has been loaded.
+   */
+  static hasOne<M extends Model>(name: string): () => M | false {
+    return function (this: Model) {
+      if (this.data.relationships) {
+        const relationshipData = this.data.relationships[name]?.data;
+
+        if (relationshipData instanceof Array) {
+          throw new Error(`Relationship ${name} on model ${this.data.type} is plural, so the hasOne method cannot be used to access it.`);
+        }
+
+        if (relationshipData) {
+          return app.store.getById<M>(relationshipData.type, relationshipData.id) as M;
+        }
+      }
+
+      return false;
+    };
+  }
+
+  /**
+   * Generate a function which returns the value of the given has-many
+   * relationship.
+   *
+   * @return false if no information about the relationship
+   *     exists; an array if it does, containing models if they have been
+   *     loaded, and undefined for those that have not.
+   * @public
+   */
+  static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false {
+    return function (this: Model) {
+      if (this.data.relationships) {
+        const relationshipData = this.data.relationships[name]?.data;
+
+        if (!(relationshipData instanceof Array)) {
+          throw new Error(`Relationship ${name} on model ${this.data.type} is singular, so the hasMany method cannot be used to access it.`);
+        }
+
+        if (relationshipData) {
+          return relationshipData.map((data) => app.store.getById<M>(data.type, data.id));
+        }
+      }
+
+      return false;
+    };
+  }
+
+  /**
+   * Transform the given value into a Date object.
+   */
+  static transformDate(value: string | null): Date | null {
+    return value ? new Date(value) : null;
+  }
+
+  /**
+   * Get a resource identifier object for the given model.
+   */
+  protected static getIdentifier(model: Model): ModelIdentifier;
+  protected static getIdentifier(model?: Model): ModelIdentifier | null {
+    if (!model || !('id' in model.data)) return null;
+
+    return {
+      type: model.data.type,
+      id: model.data.id,
+    };
+  }
+}
diff --git a/js/src/common/Store.js b/js/src/common/Store.js
deleted file mode 100644
index ebbb9d9b2..000000000
--- a/js/src/common/Store.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import app from '../common/app';
-/**
- * The `Store` class defines a local data store, and provides methods to
- * retrieve data from the API.
- */
-export default class Store {
-  constructor(models) {
-    /**
-     * The local data store. A tree of resource types to IDs, such that
-     * accessing data[type][id] will return the model for that type/ID.
-     *
-     * @type {Object}
-     * @protected
-     */
-    this.data = {};
-
-    /**
-     * The model registry. A map of resource types to the model class that
-     * should be used to represent resources of that type.
-     *
-     * @type {Object}
-     * @public
-     */
-    this.models = models;
-  }
-
-  /**
-   * Push resources contained within an API payload into the store.
-   *
-   * @param {Object} payload
-   * @return {Model|Model[]} The model(s) representing the resource(s) contained
-   *     within the 'data' key of the payload.
-   * @public
-   */
-  pushPayload(payload) {
-    if (payload.included) payload.included.map(this.pushObject.bind(this));
-
-    const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
-
-    // Attach the original payload to the model that we give back. This is
-    // useful to consumers as it allows them to access meta information
-    // associated with their request.
-    result.payload = payload;
-
-    return result;
-  }
-
-  /**
-   * Create a model to represent a resource object (or update an existing one),
-   * and push it into the store.
-   *
-   * @param {Object} data The resource object
-   * @return {Model|null} The model, or null if no model class has been
-   *     registered for this resource type.
-   * @public
-   */
-  pushObject(data) {
-    if (!this.models[data.type]) return null;
-
-    const type = (this.data[data.type] = this.data[data.type] || {});
-
-    if (type[data.id]) {
-      type[data.id].pushData(data);
-    } else {
-      type[data.id] = this.createRecord(data.type, data);
-    }
-
-    type[data.id].exists = true;
-
-    return type[data.id];
-  }
-
-  /**
-   * Make a request to the API to find record(s) of a specific type.
-   *
-   * @param {String} type The resource type.
-   * @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
-   *     Alternatively, if an object is passed, it will be handled as the
-   *     `query` parameter.
-   * @param {Object} [query]
-   * @param {Object} [options]
-   * @return {Promise}
-   * @public
-   */
-  find(type, id, query = {}, options = {}) {
-    let params = query;
-    let url = app.forum.attribute('apiUrl') + '/' + type;
-
-    if (id instanceof Array) {
-      url += '?filter[id]=' + id.join(',');
-    } else if (typeof id === 'object') {
-      params = id;
-    } else if (id) {
-      url += '/' + id;
-    }
-
-    return app
-      .request(
-        Object.assign(
-          {
-            method: 'GET',
-            url,
-            params,
-          },
-          options
-        )
-      )
-      .then(this.pushPayload.bind(this));
-  }
-
-  /**
-   * Get a record from the store by ID.
-   *
-   * @param {String} type The resource type.
-   * @param {Integer} id The resource ID.
-   * @return {Model}
-   * @public
-   */
-  getById(type, id) {
-    return this.data[type] && this.data[type][id];
-  }
-
-  /**
-   * Get a record from the store by the value of a model attribute.
-   *
-   * @param {String} type The resource type.
-   * @param {String} key The name of the method on the model.
-   * @param {*} value The value of the model attribute.
-   * @return {Model}
-   * @public
-   */
-  getBy(type, key, value) {
-    return this.all(type).filter((model) => model[key]() === value)[0];
-  }
-
-  /**
-   * Get all loaded records of a specific type.
-   *
-   * @param {String} type
-   * @return {Model[]}
-   * @public
-   */
-  all(type) {
-    const records = this.data[type];
-
-    return records ? Object.keys(records).map((id) => records[id]) : [];
-  }
-
-  /**
-   * Remove the given model from the store.
-   *
-   * @param {Model} model
-   */
-  remove(model) {
-    delete this.data[model.data.type][model.id()];
-  }
-
-  /**
-   * Create a new record of the given type.
-   *
-   * @param {String} type The resource type
-   * @param {Object} [data] Any data to initialize the model with
-   * @return {Model}
-   * @public
-   */
-  createRecord(type, data = {}) {
-    data.type = data.type || type;
-
-    return new this.models[type](data, this);
-  }
-}
diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts
new file mode 100644
index 000000000..342cc19cb
--- /dev/null
+++ b/js/src/common/Store.ts
@@ -0,0 +1,239 @@
+import app from '../common/app';
+import { FlarumRequestOptions } from './Application';
+import Model, { ModelData, SavedModelData } from './Model';
+
+export interface ApiQueryParamsSingle {
+  fields?: string[];
+  include?: string;
+  bySlug?: boolean;
+}
+
+export interface ApiQueryParamsPlural {
+  fields?: string[];
+  include?: string;
+  filter?: {
+    q: string;
+    [key: string]: string;
+  };
+  page?: {
+    offset?: number;
+    number?: number;
+    limit?: number;
+    size?: number;
+  };
+  sort?: string;
+}
+
+export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle;
+
+export interface ApiPayloadSingle {
+  data: SavedModelData;
+  included?: SavedModelData[];
+}
+
+export interface ApiPayloadPlural {
+  data: SavedModelData[];
+  included?: SavedModelData[];
+  links?: {
+    first: string;
+    next?: string;
+    prev?: string;
+  };
+}
+
+export type ApiPayload = ApiPayloadSingle | ApiPayloadPlural;
+
+export type ApiResponseSingle<M extends Model> = M & { payload: ApiPayloadSingle };
+export type ApiResponsePlural<M extends Model> = M[] & { payload: ApiPayloadPlural };
+export type ApiResponse<M extends Model> = ApiResponseSingle<M> | ApiResponsePlural<M>;
+
+interface ApiQueryRequestOptions<ResponseType> extends Omit<FlarumRequestOptions<ResponseType>, 'url'> {}
+
+interface StoreData {
+  [type: string]: Partial<Record<string, Model>>;
+}
+
+export function payloadIsPlural(payload: ApiPayload): payload is ApiPayloadPlural {
+  return Array.isArray((payload as ApiPayloadPlural).data);
+}
+
+/**
+ * The `Store` class defines a local data store, and provides methods to
+ * retrieve data from the API.
+ */
+export default class Store {
+  /**
+   * The local data store. A tree of resource types to IDs, such that
+   * accessing data[type][id] will return the model for that type/ID.
+   */
+  protected data: StoreData = {};
+
+  /**
+   * The model registry. A map of resource types to the model class that
+   * should be used to represent resources of that type.
+   */
+  models: Record<string, typeof Model>;
+
+  constructor(models: Record<string, typeof Model>) {
+    this.models = models;
+  }
+
+  /**
+   * Push resources contained within an API payload into the store.
+   *
+   * @return The model(s) representing the resource(s) contained
+   *     within the 'data' key of the payload.
+   */
+  pushPayload<M extends Model>(payload: ApiPayloadSingle): ApiResponseSingle<M>;
+  pushPayload<Ms extends Model[]>(payload: ApiPayloadPlural): ApiResponseSingle<Ms[number]>;
+  pushPayload<M extends Model | Model[]>(payload: ApiPayload): ApiResponse<FlatArray<M, 1>> {
+    if (payload.included) payload.included.map(this.pushObject.bind(this));
+
+    const models = payload.data instanceof Array ? payload.data.map((o) => this.pushObject(o, false)) : this.pushObject(payload.data, false);
+    const result = models as ApiResponse<FlatArray<M, 1>>;
+
+    // Attach the original payload to the model that we give back. This is
+    // useful to consumers as it allows them to access meta information
+    // associated with their request.
+    result.payload = payload;
+
+    return result;
+  }
+
+  /**
+   * Create a model to represent a resource object (or update an existing one),
+   * and push it into the store.
+   *
+   * @param data The resource object
+   * @return The model, or null if no model class has been
+   *     registered for this resource type.
+   */
+  pushObject<M extends Model>(data: SavedModelData): M | null;
+  pushObject<M extends Model>(data: SavedModelData, allowUnregistered: false): M;
+  pushObject<M extends Model>(data: SavedModelData, allowUnregistered = true): M | null {
+    if (!this.models[data.type]) {
+      if (allowUnregistered) {
+        return null;
+      }
+
+      throw new Error(`Cannot push object of type ${data.type}, as that type has not yet been registered in the store.`);
+    }
+
+    const type = (this.data[data.type] = this.data[data.type] || {});
+
+    // Necessary for TS to narrow correctly.
+    const curr = type[data.id] as M;
+    const instance = curr ? curr.pushData(data) : this.createRecord<M>(data.type, data);
+
+    type[data.id] = instance;
+    instance.exists = true;
+
+    return instance;
+  }
+
+  /**
+   * Make a request to the API to find record(s) of a specific type.
+   */
+  async find<M extends Model>(type: string, params: ApiQueryParamsSingle): Promise<ApiResponseSingle<M>>;
+  async find<Ms extends Model[]>(type: string, params: ApiQueryParamsPlural): Promise<ApiResponsePlural<Ms[number]>>;
+  async find<M extends Model>(
+    type: string,
+    id: string,
+    params: ApiQueryParamsSingle,
+    options?: ApiQueryRequestOptions<ApiPayloadSingle>
+  ): Promise<ApiResponseSingle<M>>;
+  async find<Ms extends Model[]>(
+    type: string,
+    ids: string[],
+    params: ApiQueryParamsPlural,
+    options?: ApiQueryRequestOptions<ApiPayloadPlural>
+  ): Promise<ApiResponsePlural<Ms[number]>>;
+  async find<M extends Model | Model[]>(
+    type: string,
+    idOrParams: string | string[] | ApiQueryParams,
+    query: ApiQueryParams = {},
+    options: ApiQueryRequestOptions<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle> = {}
+  ): Promise<ApiResponse<FlatArray<M, 1>>> {
+    let params = query;
+    let url = app.forum.attribute('apiUrl') + '/' + type;
+
+    if (idOrParams instanceof Array) {
+      url += '?filter[id]=' + idOrParams.join(',');
+    } else if (typeof idOrParams === 'object') {
+      params = idOrParams;
+    } else if (idOrParams) {
+      url += '/' + idOrParams;
+    }
+
+    return app
+      .request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>(
+        Object.assign(
+          {
+            method: 'GET',
+            url,
+            params,
+          },
+          options
+        )
+      )
+      .then((payload) => {
+        if (payloadIsPlural(payload)) {
+          return this.pushPayload<FlatArray<M, 1>[]>(payload);
+        } else {
+          return this.pushPayload<FlatArray<M, 1>>(payload);
+        }
+      });
+  }
+
+  /**
+   * Get a record from the store by ID.
+   */
+  getById<M extends Model>(type: string, id: string): M | undefined {
+    return this.data?.[type]?.[id] as M;
+  }
+
+  /**
+   * Get a record from the store by the value of a model attribute.
+   *
+   * @param type The resource type.
+   * @param key The name of the method on the model.
+   * @param value The value of the model attribute.
+   */
+  getBy<M extends Model, T = unknown>(type: string, key: keyof M, value: T): M | undefined {
+    // @ts-expect-error No way to do this safely, unfortunately.
+    return this.all(type).filter((model) => model[key]() === value)[0] as M;
+  }
+
+  /**
+   * Get all loaded records of a specific type.
+   */
+  all<M extends Model>(type: string): M[] {
+    const records = this.data[type];
+
+    return records ? (Object.values(records) as M[]) : [];
+  }
+
+  /**
+   * Remove the given model from the store.
+   */
+  remove(model: Model): void {
+    delete this.data[model.data.type as string][model.id() as string];
+  }
+
+  /**
+   * Create a new record of the given type.
+   *
+   * @param {String} type The resource type
+   * @param {Object} [data] Any data to initialize the model with
+   * @return {Model}
+   * @public
+   */
+  createRecord<M extends Model>(type: string, data: ModelData = {}): M {
+    data.type = data.type || type;
+
+    // @ts-expect-error this will complain about initializing abstract models,
+    // but we can safely assume that all models registered with the store are
+    // not abstract.
+    return new this.models[type](data, this);
+  }
+}
diff --git a/js/src/common/helpers/username.tsx b/js/src/common/helpers/username.tsx
index 019138c05..a0a807306 100644
--- a/js/src/common/helpers/username.tsx
+++ b/js/src/common/helpers/username.tsx
@@ -6,7 +6,7 @@ import User from '../models/User';
  * The `username` helper displays a user's username in a <span class="username">
  * tag. If the user doesn't exist, the username will be displayed as [deleted].
  */
-export default function username(user: User): Mithril.Vnode {
+export default function username(user: User | null | undefined | false): Mithril.Vnode {
   const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
 
   return <span className="username">{name}</span>;
diff --git a/js/src/common/models/Discussion.js b/js/src/common/models/Discussion.js
deleted file mode 100644
index 3f91e022a..000000000
--- a/js/src/common/models/Discussion.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import app from '../../common/app';
-import Model from '../Model';
-import computed from '../utils/computed';
-import ItemList from '../utils/ItemList';
-import Badge from '../components/Badge';
-
-export default class Discussion extends Model {}
-
-Object.assign(Discussion.prototype, {
-  title: Model.attribute('title'),
-  slug: Model.attribute('slug'),
-
-  createdAt: Model.attribute('createdAt', Model.transformDate),
-  user: Model.hasOne('user'),
-  firstPost: Model.hasOne('firstPost'),
-
-  lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
-  lastPostedUser: Model.hasOne('lastPostedUser'),
-  lastPost: Model.hasOne('lastPost'),
-  lastPostNumber: Model.attribute('lastPostNumber'),
-
-  commentCount: Model.attribute('commentCount'),
-  replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)),
-  posts: Model.hasMany('posts'),
-  mostRelevantPost: Model.hasOne('mostRelevantPost'),
-
-  lastReadAt: Model.attribute('lastReadAt', Model.transformDate),
-  lastReadPostNumber: Model.attribute('lastReadPostNumber'),
-  isUnread: computed('unreadCount', (unreadCount) => !!unreadCount),
-  isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
-
-  hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
-  hiddenUser: Model.hasOne('hiddenUser'),
-  isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
-
-  canReply: Model.attribute('canReply'),
-  canRename: Model.attribute('canRename'),
-  canHide: Model.attribute('canHide'),
-  canDelete: Model.attribute('canDelete'),
-
-  /**
-   * Remove a post from the discussion's posts relationship.
-   *
-   * @param {Integer} id The ID of the post to remove.
-   * @public
-   */
-  removePost(id) {
-    const relationships = this.data.relationships;
-    const posts = relationships && relationships.posts;
-
-    if (posts) {
-      posts.data.some((data, i) => {
-        if (id === data.id) {
-          posts.data.splice(i, 1);
-          return true;
-        }
-      });
-    }
-  },
-
-  /**
-   * Get the estimated number of unread posts in this discussion for the current
-   * user.
-   *
-   * @return {Integer}
-   * @public
-   */
-  unreadCount() {
-    const user = app.session.user;
-
-    if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
-      const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
-      // If posts have been deleted, it's possible that the unread count could exceed the
-      // actual post count. As such, we take the min of the two to ensure this isn't an issue.
-      return Math.min(unreadCount, this.commentCount());
-    }
-
-    return 0;
-  },
-
-  /**
-   * Get the Badge components that apply to this discussion.
-   *
-   * @return {ItemList}
-   * @public
-   */
-  badges() {
-    const items = new ItemList();
-
-    if (this.isHidden()) {
-      items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
-    }
-
-    return items;
-  },
-
-  /**
-   * Get a list of all of the post IDs in this discussion.
-   *
-   * @return {Array}
-   * @public
-   */
-  postIds() {
-    const posts = this.data.relationships.posts;
-
-    return posts ? posts.data.map((link) => link.id) : [];
-  },
-});
diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.ts
new file mode 100644
index 000000000..1fbf59b9d
--- /dev/null
+++ b/js/src/common/models/Discussion.ts
@@ -0,0 +1,146 @@
+import app from '../../common/app';
+import Model from '../Model';
+import computed from '../utils/computed';
+import ItemList from '../utils/ItemList';
+import Badge from '../components/Badge';
+import Mithril from 'mithril';
+import Post from './Post';
+import User from './User';
+
+export default class Discussion extends Model {
+  title() {
+    return Model.attribute<string>('title').call(this);
+  }
+  slug() {
+    return Model.attribute<string>('slug').call(this);
+  }
+
+  createdAt() {
+    return Model.attribute<Date | null, string | null>('createdAt', Model.transformDate).call(this);
+  }
+  user() {
+    return Model.hasOne<User>('user').call(this);
+  }
+  firstPost() {
+    return Model.hasOne<Post>('firstPost').call(this);
+  }
+
+  lastPostedAt() {
+    return Model.attribute<Date | null, string | null>('lastPostedAt', Model.transformDate).call(this);
+  }
+  lastPostedUser() {
+    return Model.hasOne<User>('lastPostedUser').call(this);
+  }
+  lastPost() {
+    return Model.hasOne<Post>('lastPost').call(this);
+  }
+  lastPostNumber() {
+    return Model.attribute<number | null>('lastPostNumber').call(this);
+  }
+
+  commentCount() {
+    return Model.attribute<number | null>('commentCount').call(this);
+  }
+  replyCount() {
+    return computed<number, this>('commentCount', (commentCount) => Math.max(0, (commentCount as number) - 1)).call(this);
+  }
+  posts() {
+    return Model.hasMany<Post>('posts').call(this);
+  }
+  mostRelevantPost() {
+    return Model.hasOne<Post>('mostRelevantPost').call(this);
+  }
+
+  lastReadAt() {
+    return Model.attribute<Date | null, string | null>('lastReadAt', Model.transformDate).call(this);
+  }
+  lastReadPostNumber() {
+    return Model.attribute<number | null>('lastReadPostNumber').call(this);
+  }
+  isUnread() {
+    return computed<boolean, this>('unreadCount', (unreadCount) => !!unreadCount).call(this);
+  }
+  isRead() {
+    return computed<boolean, this>('unreadCount', (unreadCount) => app.session.user && !unreadCount).call(this);
+  }
+
+  hiddenAt() {
+    return Model.attribute<Date | null, string | null>('hiddenAt', Model.transformDate).call(this);
+  }
+  hiddenUser() {
+    return Model.hasOne<User>('hiddenUser').call(this);
+  }
+  isHidden() {
+    return computed<boolean, this>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
+  }
+
+  canReply() {
+    return Model.attribute<boolean | null>('canReply').call(this);
+  }
+  canRename() {
+    return Model.attribute<boolean | null>('canRename').call(this);
+  }
+  canHide() {
+    return Model.attribute<boolean | null>('canHide').call(this);
+  }
+  canDelete() {
+    return Model.attribute<boolean | null>('canDelete').call(this);
+  }
+
+  /**
+   * Remove a post from the discussion's posts relationship.
+   */
+  removePost(id: string): void {
+    const posts = this.rawRelationship<Post[]>('posts');
+
+    if (!posts) {
+      return;
+    }
+
+    posts.some((data, i) => {
+      if (id === data.id) {
+        posts.splice(i, 1);
+        return true;
+      }
+
+      return false;
+    });
+  }
+
+  /**
+   * Get the estimated number of unread posts in this discussion for the current
+   * user.
+   */
+  unreadCount(): number {
+    const user = app.session.user;
+
+    if (user && user.markedAllAsReadAt().getTime() < this.lastPostedAt()?.getTime()!) {
+      const unreadCount = Math.max(0, (this.lastPostNumber() ?? 0) - (this.lastReadPostNumber() || 0));
+      // If posts have been deleted, it's possible that the unread count could exceed the
+      // actual post count. As such, we take the min of the two to ensure this isn't an issue.
+      return Math.min(unreadCount, this.commentCount() ?? 0);
+    }
+
+    return 0;
+  }
+
+  /**
+   * Get the Badge components that apply to this discussion.
+   */
+  badges(): ItemList<Mithril.Children> {
+    const items = new ItemList<Mithril.Children>();
+
+    if (this.isHidden()) {
+      items.add('hidden', Badge.component({ type: 'hidden', icon: 'fas fa-trash', label: app.translator.trans('core.lib.badge.hidden_tooltip') }));
+    }
+
+    return items;
+  }
+
+  /**
+   * Get a list of all of the post IDs in this discussion.
+   */
+  postIds(): string[] {
+    return this.rawRelationship<Post[]>('posts')?.map((link) => link.id) ?? [];
+  }
+}
diff --git a/js/src/common/models/Forum.js b/js/src/common/models/Forum.ts
similarity index 100%
rename from js/src/common/models/Forum.js
rename to js/src/common/models/Forum.ts
diff --git a/js/src/common/models/Group.js b/js/src/common/models/Group.js
deleted file mode 100644
index 46087032a..000000000
--- a/js/src/common/models/Group.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Model from '../Model';
-
-class Group extends Model {}
-
-Object.assign(Group.prototype, {
-  nameSingular: Model.attribute('nameSingular'),
-  namePlural: Model.attribute('namePlural'),
-  color: Model.attribute('color'),
-  icon: Model.attribute('icon'),
-  isHidden: Model.attribute('isHidden'),
-});
-
-Group.ADMINISTRATOR_ID = '1';
-Group.GUEST_ID = '2';
-Group.MEMBER_ID = '3';
-
-export default Group;
diff --git a/js/src/common/models/Group.ts b/js/src/common/models/Group.ts
new file mode 100644
index 000000000..71cc420f2
--- /dev/null
+++ b/js/src/common/models/Group.ts
@@ -0,0 +1,25 @@
+import Model from '../Model';
+
+export default class Group extends Model {
+  static ADMINISTRATOR_ID = '1';
+  static GUEST_ID = '2';
+  static MEMBER_ID = '3';
+
+  nameSingular() {
+    return Model.attribute<string>('nameSingular').call(this);
+  }
+  namePlural() {
+    return Model.attribute<string>('namePlural').call(this);
+  }
+
+  color() {
+    return Model.attribute<string>('color').call(this);
+  }
+  icon() {
+    return Model.attribute<string>('icon').call(this);
+  }
+
+  isHidden() {
+    return Model.attribute<boolean>('isHidden').call(this);
+  }
+}
diff --git a/js/src/common/models/Notification.js b/js/src/common/models/Notification.js
deleted file mode 100644
index fb849ec5e..000000000
--- a/js/src/common/models/Notification.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Model from '../Model';
-
-export default class Notification extends Model {}
-
-Object.assign(Notification.prototype, {
-  contentType: Model.attribute('contentType'),
-  content: Model.attribute('content'),
-  createdAt: Model.attribute('createdAt', Model.transformDate),
-
-  isRead: Model.attribute('isRead'),
-
-  user: Model.hasOne('user'),
-  fromUser: Model.hasOne('fromUser'),
-  subject: Model.hasOne('subject'),
-});
diff --git a/js/src/common/models/Notification.ts b/js/src/common/models/Notification.ts
new file mode 100644
index 000000000..ef763d1bc
--- /dev/null
+++ b/js/src/common/models/Notification.ts
@@ -0,0 +1,28 @@
+import Model from '../Model';
+import User from './User';
+
+export default class Notification extends Model {
+  contentType() {
+    return Model.attribute<string>('contentType').call(this);
+  }
+  content() {
+    return Model.attribute<string>('content').call(this);
+  }
+  createdAt() {
+    return Model.attribute<Date | null, string | null>('createdAt', Model.transformDate).call(this);
+  }
+
+  isRead() {
+    return Model.attribute<boolean>('isRead').call(this);
+  }
+
+  user() {
+    return Model.hasOne<User>('user').call(this);
+  }
+  fromUser() {
+    return Model.hasOne<User>('fromUser').call(this);
+  }
+  subject() {
+    return Model.hasOne('subject').call(this);
+  }
+}
diff --git a/js/src/common/models/Post.js b/js/src/common/models/Post.js
deleted file mode 100644
index 29a122cb9..000000000
--- a/js/src/common/models/Post.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Model from '../Model';
-import computed from '../utils/computed';
-import { getPlainContent } from '../utils/string';
-
-export default class Post extends Model {}
-
-Object.assign(Post.prototype, {
-  number: Model.attribute('number'),
-  discussion: Model.hasOne('discussion'),
-
-  createdAt: Model.attribute('createdAt', Model.transformDate),
-  user: Model.hasOne('user'),
-
-  contentType: Model.attribute('contentType'),
-  content: Model.attribute('content'),
-  contentHtml: Model.attribute('contentHtml'),
-  renderFailed: Model.attribute('renderFailed'),
-  contentPlain: computed('contentHtml', getPlainContent),
-
-  editedAt: Model.attribute('editedAt', Model.transformDate),
-  editedUser: Model.hasOne('editedUser'),
-  isEdited: computed('editedAt', (editedAt) => !!editedAt),
-
-  hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
-  hiddenUser: Model.hasOne('hiddenUser'),
-  isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
-
-  canEdit: Model.attribute('canEdit'),
-  canHide: Model.attribute('canHide'),
-  canDelete: Model.attribute('canDelete'),
-});
diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts
new file mode 100644
index 000000000..f9f70981c
--- /dev/null
+++ b/js/src/common/models/Post.ts
@@ -0,0 +1,67 @@
+import Model from '../Model';
+import computed from '../utils/computed';
+import { getPlainContent } from '../utils/string';
+import Discussion from './Discussion';
+import User from './User';
+
+export default class Post extends Model {
+  number() {
+    return Model.attribute<number>('number').call(this);
+  }
+  discussion() {
+    return Model.hasOne<Discussion>('discussion').call(this);
+  }
+
+  createdAt() {
+    return Model.attribute<Date | null, string>('createdAt', Model.transformDate).call(this);
+  }
+  user() {
+    return Model.hasOne<User>('user').call(this);
+  }
+
+  contentType() {
+    return Model.attribute<string>('contentType').call(this);
+  }
+  content() {
+    return Model.attribute<string>('content').call(this);
+  }
+  contentHtml() {
+    return Model.attribute<string>('contentHtml').call(this);
+  }
+  renderFailed() {
+    return Model.attribute<boolean>('renderFailed').call(this);
+  }
+  contentPlain() {
+    return computed<string>('contentHtml', getPlainContent as (content: unknown) => string).call(this);
+  }
+
+  editedAt() {
+    return Model.attribute<Date | null, string>('editedAt', Model.transformDate).call(this);
+  }
+  editedUser() {
+    return Model.hasOne<User>('editedUser').call(this);
+  }
+  isEdited() {
+    return computed<boolean>('editedAt', (editedAt) => !!editedAt).call(this);
+  }
+
+  hiddenAt() {
+    return Model.attribute<Date | null, string>('hiddenAt', Model.transformDate).call(this);
+  }
+  hiddenUser() {
+    return Model.hasOne<User>('hiddenUser').call(this);
+  }
+  isHidden() {
+    return computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
+  }
+
+  canEdit() {
+    return Model.attribute<boolean>('canEdit').call(this);
+  }
+  canHide() {
+    return Model.attribute<boolean>('canHide').call(this);
+  }
+  canDelete() {
+    return Model.attribute<boolean>('canDelete').call(this);
+  }
+}
diff --git a/js/src/common/models/User.js b/js/src/common/models/User.js
deleted file mode 100644
index 97de0d8d3..000000000
--- a/js/src/common/models/User.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/*global ColorThief*/
-
-import Model from '../Model';
-import stringToColor from '../utils/stringToColor';
-import ItemList from '../utils/ItemList';
-import computed from '../utils/computed';
-import GroupBadge from '../components/GroupBadge';
-
-export default class User extends Model {}
-
-Object.assign(User.prototype, {
-  username: Model.attribute('username'),
-  slug: Model.attribute('slug'),
-  displayName: Model.attribute('displayName'),
-  email: Model.attribute('email'),
-  isEmailConfirmed: Model.attribute('isEmailConfirmed'),
-  password: Model.attribute('password'),
-
-  avatarUrl: Model.attribute('avatarUrl'),
-  preferences: Model.attribute('preferences'),
-  groups: Model.hasMany('groups'),
-
-  joinTime: Model.attribute('joinTime', Model.transformDate),
-  lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate),
-  markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
-  unreadNotificationCount: Model.attribute('unreadNotificationCount'),
-  newNotificationCount: Model.attribute('newNotificationCount'),
-
-  discussionCount: Model.attribute('discussionCount'),
-  commentCount: Model.attribute('commentCount'),
-
-  canEdit: Model.attribute('canEdit'),
-  canEditCredentials: Model.attribute('canEditCredentials'),
-  canEditGroups: Model.attribute('canEditGroups'),
-  canDelete: Model.attribute('canDelete'),
-
-  avatarColor: null,
-  color: computed('displayName', 'avatarUrl', 'avatarColor', function (displayName, avatarUrl, avatarColor) {
-    // If we've already calculated and cached the dominant color of the user's
-    // avatar, then we can return that in RGB format. If we haven't, we'll want
-    // to calculate it. Unless the user doesn't have an avatar, in which case
-    // we generate a color from their display name.
-    if (avatarColor) {
-      return 'rgb(' + avatarColor.join(', ') + ')';
-    } else if (avatarUrl) {
-      this.calculateAvatarColor();
-      return '';
-    }
-
-    return '#' + stringToColor(displayName);
-  }),
-
-  /**
-   * Check whether or not the user has been seen in the last 5 minutes.
-   *
-   * @return {Boolean}
-   * @public
-   */
-  isOnline() {
-    return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
-  },
-
-  /**
-   * Get the Badge components that apply to this user.
-   *
-   * @return {ItemList}
-   */
-  badges() {
-    const items = new ItemList();
-    const groups = this.groups();
-
-    if (groups) {
-      groups.forEach((group) => {
-        items.add('group' + group.id(), GroupBadge.component({ group }));
-      });
-    }
-
-    return items;
-  },
-
-  /**
-   * Calculate the dominant color of the user's avatar. The dominant color will
-   * be set to the `avatarColor` property once it has been calculated.
-   *
-   * @protected
-   */
-  calculateAvatarColor() {
-    const image = new Image();
-    const user = this;
-
-    image.onload = function () {
-      try {
-        const colorThief = new ColorThief();
-        user.avatarColor = colorThief.getColor(this);
-      } catch (e) {
-        // Completely white avatars throw errors due to a glitch in color thief
-        // See https://github.com/lokesh/color-thief/issues/40
-        if (e instanceof TypeError) {
-          user.avatarColor = [255, 255, 255];
-        } else {
-          throw e;
-        }
-      }
-      user.freshness = new Date();
-      m.redraw();
-    };
-    image.crossOrigin = 'anonymous';
-    image.src = this.avatarUrl();
-  },
-
-  /**
-   * Update the user's preferences.
-   *
-   * @param {Object} newPreferences
-   * @return {Promise}
-   */
-  savePreferences(newPreferences) {
-    const preferences = this.preferences();
-
-    Object.assign(preferences, newPreferences);
-
-    return this.save({ preferences });
-  },
-});
diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.ts
new file mode 100644
index 000000000..6d66ef458
--- /dev/null
+++ b/js/src/common/models/User.ts
@@ -0,0 +1,164 @@
+import ColorThief, { Color } from 'color-thief-browser';
+
+import Model from '../Model';
+import stringToColor from '../utils/stringToColor';
+import ItemList from '../utils/ItemList';
+import computed from '../utils/computed';
+import GroupBadge from '../components/GroupBadge';
+import Mithril from 'mithril';
+
+export default class User extends Model {
+  username() {
+    return Model.attribute<string>('username').call(this);
+  }
+  slug() {
+    return Model.attribute<string>('slug').call(this);
+  }
+  displayName() {
+    return Model.attribute<string>('displayName').call(this);
+  }
+
+  email() {
+    return Model.attribute<string | null>('email').call(this);
+  }
+  isEmailConfirmed() {
+    return Model.attribute<boolean | null>('isEmailConfirmed').call(this);
+  }
+
+  password() {
+    return Model.attribute<string | null>('password').call(this);
+  }
+
+  avatarUrl() {
+    return Model.attribute<string>('avatarUrl').call(this);
+  }
+
+  preferences() {
+    return Model.attribute<Record<string, any> | null>('preferences').call(this);
+  }
+
+  groups() {
+    return Model.hasMany('groups').call(this);
+  }
+
+  joinTime() {
+    return Model.attribute<Date | null, string | null>('joinTime', Model.transformDate).call(this);
+  }
+
+  lastSeenAt() {
+    return Model.attribute<Date | null, string | null>('lastSeenAt', Model.transformDate).call(this);
+  }
+
+  markedAllAsReadAt() {
+    return Model.attribute<Date | null, string | null>('markedAllAsReadAt', Model.transformDate).call(this);
+  }
+
+  unreadNotificationCount() {
+    return Model.attribute<number | null>('unreadNotificationCount').call(this);
+  }
+  newNotificationCount() {
+    return Model.attribute<number | null>('newNotificationCount').call(this);
+  }
+
+  discussionCount() {
+    return Model.attribute<number | null>('discussionCount').call(this);
+  }
+  commentCount() {
+    return Model.attribute<number | null>('commentCount').call(this);
+  }
+
+  canEdit() {
+    return Model.attribute<boolean | null>('canEdit').call(this);
+  }
+  canEditCredentials() {
+    return Model.attribute<boolean | null>('canEditCredentials').call(this);
+  }
+  canEditGroups() {
+    return Model.attribute<boolean | null>('canEditGroups').call(this);
+  }
+  canDelete() {
+    return Model.attribute<boolean | null>('canDelete').call(this);
+  }
+
+  color() {
+    return computed<string, User>('displayName', 'avatarUrl', 'avatarColor', (displayName, avatarUrl, avatarColor) => {
+      // If we've already calculated and cached the dominant color of the user's
+      // avatar, then we can return that in RGB format. If we haven't, we'll want
+      // to calculate it. Unless the user doesn't have an avatar, in which case
+      // we generate a color from their display name.
+      if (avatarColor) {
+        return `rgb(${(avatarColor as Color).join(', ')})`;
+      } else if (avatarUrl) {
+        this.calculateAvatarColor();
+        return '';
+      }
+
+      return '#' + stringToColor(displayName as string);
+    }).call(this);
+  }
+
+  protected avatarColor: Color | null = null;
+
+  /**
+   * Check whether or not the user has been seen in the last 5 minutes.
+   */
+  isOnline(): boolean {
+    return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
+  }
+
+  /**
+   * Get the Badge components that apply to this user.
+   */
+  badges(): ItemList<Mithril.Children> {
+    const items = new ItemList<Mithril.Children>();
+    const groups = this.groups();
+
+    if (groups) {
+      groups.forEach((group) => {
+        items.add(`group${group?.id()}`, GroupBadge.component({ group }));
+      });
+    }
+
+    return items;
+  }
+
+  /**
+   * Calculate the dominant color of the user's avatar. The dominant color will
+   * be set to the `avatarColor` property once it has been calculated.
+   */
+  protected calculateAvatarColor() {
+    const image = new Image();
+    const user = this;
+
+    // @ts-expect-error This shouldn't be failing.
+    image.onload = function (this: HTMLImageElement) {
+      try {
+        const colorThief = new ColorThief();
+        user.avatarColor = colorThief.getColor(this);
+      } catch (e) {
+        // Completely white avatars throw errors due to a glitch in color thief
+        // See https://github.com/lokesh/color-thief/issues/40
+        if (e instanceof TypeError) {
+          user.avatarColor = [255, 255, 255];
+        } else {
+          throw e;
+        }
+      }
+      user.freshness = new Date();
+      m.redraw();
+    };
+    image.crossOrigin = 'anonymous';
+    image.src = this.avatarUrl();
+  }
+
+  /**
+   * Update the user's preferences.
+   */
+  savePreferences(newPreferences: Record<string, unknown>): Promise<this> {
+    const preferences = this.preferences();
+
+    Object.assign(preferences, newPreferences);
+
+    return this.save({ preferences });
+  }
+}
diff --git a/js/src/common/states/PaginatedListState.ts b/js/src/common/states/PaginatedListState.ts
index 5f13baf22..53d08464b 100644
--- a/js/src/common/states/PaginatedListState.ts
+++ b/js/src/common/states/PaginatedListState.ts
@@ -1,5 +1,6 @@
 import app from '../../common/app';
 import Model from '../Model';
+import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
 
 export interface Page<TModel> {
   number: number;
@@ -19,6 +20,10 @@ export interface PaginatedListParams {
   [key: string]: any;
 }
 
+export interface PaginatedListRequestParams extends Omit<ApiQueryParamsPlural, 'include'> {
+  include?: string | string[];
+}
+
 export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
   protected location!: PaginationLocation;
   protected pageSize: number;
@@ -39,7 +44,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
 
   abstract get type(): string;
 
-  public clear() {
+  public clear(): void {
     this.pages = [];
 
     m.redraw();
@@ -69,15 +74,15 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
       .finally(() => (this.loadingNext = false));
   }
 
-  protected parseResults(pg: number, results: T[]) {
+  protected parseResults(pg: number, results: ApiResponsePlural<T>): void {
     const pageNum = Number(pg);
 
-    const links = results.payload?.links || {};
+    const links = results.payload?.links;
     const page = {
       number: pageNum,
       items: results,
-      hasNext: !!links.next,
-      hasPrev: !!links.prev,
+      hasNext: !!links?.next,
+      hasPrev: !!links?.prev,
     };
 
     if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) {
@@ -94,18 +99,21 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
   /**
    * Load a new page of results.
    */
-  protected loadPage(page = 1): Promise<T[]> {
-    const params = this.requestParams();
-    params.page = {
-      ...params.page,
-      offset: this.pageSize * (page - 1),
+  protected loadPage(page = 1): Promise<ApiResponsePlural<T>> {
+    const reqParams = this.requestParams();
+
+    const include = Array.isArray(reqParams.include) ? reqParams.include.join(',') : reqParams.include;
+
+    const params: ApiQueryParamsPlural = {
+      ...reqParams,
+      page: {
+        ...reqParams.page,
+        offset: this.pageSize * (page - 1),
+      },
+      include,
     };
 
-    if (Array.isArray(params.include)) {
-      params.include = params.include.join(',');
-    }
-
-    return app.store.find(this.type, params);
+    return app.store.find<T[]>(this.type, params);
   }
 
   /**
@@ -115,7 +123,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
    * @abstract
    * @see loadPage
    */
-  protected requestParams(): any {
+  protected requestParams(): PaginatedListRequestParams {
     return this.params;
   }
 
@@ -137,7 +145,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
     return Promise.resolve();
   }
 
-  public refresh(page: number = 1) {
+  public refresh(page: number = 1): Promise<void> {
     this.initialLoading = true;
     this.loadingPrev = false;
     this.loadingNext = false;
@@ -147,14 +155,14 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
     this.location = { page };
 
     return this.loadPage()
-      .then((results: T[]) => {
+      .then((results) => {
         this.pages = [];
         this.parseResults(this.location.page, results);
       })
       .finally(() => (this.initialLoading = false));
   }
 
-  public getPages() {
+  public getPages(): Page<T>[] {
     return this.pages;
   }
   public getLocation(): PaginationLocation {
@@ -203,7 +211,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
   /**
    * Stored state parameters.
    */
-  public getParams(): any {
+  public getParams(): P {
     return this.params;
   }
 
diff --git a/js/src/common/utils/computed.js b/js/src/common/utils/computed.ts
similarity index 60%
rename from js/src/common/utils/computed.js
rename to js/src/common/utils/computed.ts
index 261f7b30b..8bf1cb3f4 100644
--- a/js/src/common/utils/computed.js
+++ b/js/src/common/utils/computed.ts
@@ -1,3 +1,5 @@
+import Model from '../Model';
+
 /**
  * The `computed` utility creates a function that will cache its output until
  * any of the dependent values are dirty.
@@ -7,20 +9,21 @@
  *     dependent values.
  * @return {Function}
  */
-export default function computed(...dependentKeys) {
-  const keys = dependentKeys.slice(0, -1);
-  const compute = dependentKeys.slice(-1)[0];
+export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T {
+  const keys = args.slice(0, -1) as string[];
+  const compute = args.slice(-1)[0] as (this: M, ...args: unknown[]) => T;
 
-  const dependentValues = {};
-  let computedValue;
+  const dependentValues: Record<string, unknown> = {};
+  let computedValue: T;
 
-  return function () {
+  return function (this: M) {
     let recompute = false;
 
     // Read all of the dependent values. If any of them have changed since last
     // time, then we'll want to recompute our output.
     keys.forEach((key) => {
-      const value = typeof this[key] === 'function' ? this[key]() : this[key];
+      const attr = (this as Record<string, unknown | (() => unknown)>)[key];
+      const value = typeof attr === 'function' ? attr.call(this) : attr;
 
       if (dependentValues[key] !== value) {
         recompute = true;
diff --git a/js/src/forum/components/DiscussionPage.tsx b/js/src/forum/components/DiscussionPage.tsx
index 19c3f7690..6ae4b1d94 100644
--- a/js/src/forum/components/DiscussionPage.tsx
+++ b/js/src/forum/components/DiscussionPage.tsx
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
 import PostStreamState from '../states/PostStreamState';
 import Discussion from '../../common/models/Discussion';
 import Post from '../../common/models/Post';
+import { ApiResponseSingle } from '../../common/Store';
 
 export interface IDiscussionPageAttrs extends IPageAttrs {
   id: string;
@@ -163,7 +164,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
    * Load the discussion from the API or use the preloaded one.
    */
   load() {
-    const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null;
+    const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
     if (preloadedDiscussion) {
       // We must wrap this in a setTimeout because if we are mounting this
       // component for the first time on page load, then any calls to m.redraw
@@ -173,7 +174,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
     } else {
       const params = this.requestParams();
 
-      app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
+      app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
     }
 
     m.redraw();
@@ -195,7 +196,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
   /**
    * Initialize the component to display the given discussion.
    */
-  show(discussion: Discussion) {
+  show(discussion: ApiResponseSingle<Discussion>) {
     app.history.push('discussion', discussion.title());
     app.setTitle(discussion.title());
     app.setTitleCount(0);
@@ -207,7 +208,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
     // extensions. We need to distinguish the two so we don't end up displaying
     // the wrong posts. We do so by filtering out the posts that don't have
     // the 'discussion' relationship linked, then sorting and splicing.
-    let includedPosts = [];
+    let includedPosts: (Post | undefined)[] = [];
     if (discussion.payload && discussion.payload.included) {
       const discussionId = discussion.id();
 
@@ -217,10 +218,11 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
             record.type === 'posts' &&
             record.relationships &&
             record.relationships.discussion &&
+            !Array.isArray(record.relationships.discussion.data) &&
             record.relationships.discussion.data.id === discussionId
         )
-        .map((record) => app.store.getById('posts', record.id))
-        .sort((a: Post, b: Post) => a.createdAt() - b.createdAt())
+        .map((record) => app.store.getById<Post>('posts', record.id))
+        .sort((a?: Post, b?: Post) => a?.createdAt()?.getTime()! - b?.createdAt()?.getTime()!)
         .slice(0, 20);
     }
 
@@ -228,7 +230,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
     // posts we want to display. Tell the stream to scroll down and highlight
     // the specific post that was routed to.
     this.stream = new PostStreamState(discussion, includedPosts);
-    this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
+    this.stream.goToNumber(m.route.param('near') || (includedPosts[0]?.number() ?? 0), true).then(() => {
       this.discussion = discussion;
 
       app.current.set('discussion', discussion);
diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx
index 4cbd4dbfb..804b623ea 100644
--- a/js/src/forum/components/DiscussionsSearchSource.tsx
+++ b/js/src/forum/components/DiscussionsSearchSource.tsx
@@ -24,7 +24,7 @@ export default class DiscussionsSearchSource implements SearchSource {
       include: 'mostRelevantPost',
     };
 
-    return app.store.find('discussions', params).then((results) => {
+    return app.store.find<Discussion[]>('discussions', params).then((results) => {
       this.results.set(query, results);
       m.redraw();
     });
diff --git a/js/src/forum/components/Search.tsx b/js/src/forum/components/Search.tsx
index 169197d13..13c1d838c 100644
--- a/js/src/forum/components/Search.tsx
+++ b/js/src/forum/components/Search.tsx
@@ -163,7 +163,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
     const maxHeight =
       window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
 
-    this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`);
+    (this.element.querySelector('.Search-results') as HTMLElement).style?.setProperty('max-height', `${maxHeight}px`);
   }
 
   onupdate(vnode: Mithril.VnodeDOM<T, this>) {
diff --git a/js/src/forum/components/UsersSearchSource.tsx b/js/src/forum/components/UsersSearchSource.tsx
index 4d2776b60..73e081b07 100644
--- a/js/src/forum/components/UsersSearchSource.tsx
+++ b/js/src/forum/components/UsersSearchSource.tsx
@@ -17,7 +17,7 @@ export default class UsersSearchResults implements SearchSource {
 
   async search(query: string): Promise<void> {
     return app.store
-      .find('users', {
+      .find<User[]>('users', {
         filter: { q: query },
         page: { limit: 5 },
       })
@@ -33,7 +33,7 @@ export default class UsersSearchResults implements SearchSource {
     const results = (this.results.get(query) || [])
       .concat(
         app.store
-          .all('users')
+          .all<User>('users')
           .filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query))
       )
       .filter((e, i, arr) => arr.lastIndexOf(e) === i)
diff --git a/js/src/forum/states/DiscussionListState.ts b/js/src/forum/states/DiscussionListState.ts
index 7e3715cd6..8621561b3 100644
--- a/js/src/forum/states/DiscussionListState.ts
+++ b/js/src/forum/states/DiscussionListState.ts
@@ -1,12 +1,7 @@
 import app from '../../forum/app';
-import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState';
+import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState';
 import Discussion from '../../common/models/Discussion';
-
-export interface IRequestParams {
-  include: string[];
-  filter: Record<string, string>;
-  sort?: string;
-}
+import { ApiQueryParamsPlural, ApiResponsePlural } from '../../common/Store';
 
 export interface DiscussionListParams extends PaginatedListParams {
   sort?: string;
@@ -23,14 +18,13 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
     return 'discussions';
   }
 
-  requestParams(): IRequestParams {
-    const params: IRequestParams = {
+  requestParams(): PaginatedListRequestParams {
+    const params = {
       include: ['user', 'lastPostedUser'],
       filter: this.params.filter || {},
+      sort: this.sortMap()[this.params.sort ?? ''],
     };
 
-    params.sort = this.sortMap()[this.params.sort ?? ''];
-
     if (this.params.q) {
       params.filter.q = this.params.q;
       params.include.push('mostRelevantPost', 'mostRelevantPost.user');
@@ -39,8 +33,8 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
     return params;
   }
 
-  protected loadPage(page: number = 1): Promise<Discussion[]> {
-    const preloadedDiscussions = app.preloadedApiDocument() as Discussion[] | null;
+  protected loadPage(page: number = 1): Promise<ApiResponsePlural<Discussion>> {
+    const preloadedDiscussions = app.preloadedApiDocument<Discussion[]>();
 
     if (preloadedDiscussions) {
       this.initialLoading = false;