From 28692962bcd437659e05489f55a86fa5269e0681 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sun, 14 Jan 2024 14:25:42 +0000
Subject: [PATCH] refactor: replace io-ts with zod

---
 backend/package-lock.json                     |   9 -
 backend/package.json                          |   1 -
 backend/src/commandTypes.ts                   |   2 +-
 backend/src/configValidator.ts                |  16 +-
 backend/src/pluginUtils.ts                    |  48 +--
 .../plugins/AutoDelete/AutoDeletePlugin.ts    |   7 +-
 backend/src/plugins/AutoDelete/types.ts       |  13 +-
 .../AutoReactions/AutoReactionsPlugin.ts      |   7 +-
 backend/src/plugins/AutoReactions/types.ts    |   9 +-
 backend/src/plugins/Automod/AutomodPlugin.ts  | 132 +-------
 .../src/plugins/Automod/actions/addRoles.ts   |   9 +-
 .../plugins/Automod/actions/addToCounter.ts   |  15 +-
 backend/src/plugins/Automod/actions/alert.ts  |  22 +-
 .../plugins/Automod/actions/archiveThread.ts  |   7 +-
 .../Automod/actions/availableActions.ts       |  26 +-
 backend/src/plugins/Automod/actions/ban.ts    |  30 +-
 .../plugins/Automod/actions/changeNickname.ts |  14 +-
 .../plugins/Automod/actions/changePerms.ts    |  22 +-
 backend/src/plugins/Automod/actions/clean.ts  |   5 +-
 .../plugins/Automod/actions/exampleAction.ts  |   9 +-
 backend/src/plugins/Automod/actions/kick.ts   |  22 +-
 backend/src/plugins/Automod/actions/log.ts    |   5 +-
 backend/src/plugins/Automod/actions/mute.ts   |  28 +-
 .../plugins/Automod/actions/removeRoles.ts    |   8 +-
 backend/src/plugins/Automod/actions/reply.ts  |  22 +-
 .../Automod/actions/setAntiraidLevel.ts       |   6 +-
 .../src/plugins/Automod/actions/setCounter.ts |  11 +-
 .../plugins/Automod/actions/setSlowmode.ts    |  14 +-
 .../plugins/Automod/actions/startThread.ts    |  20 +-
 backend/src/plugins/Automod/actions/warn.ts   |  22 +-
 .../functions/createMessageSpamTrigger.ts     |  19 +-
 backend/src/plugins/Automod/helpers.ts        |  38 +--
 backend/src/plugins/Automod/info.ts           |   4 +-
 .../plugins/Automod/triggers/antiraidLevel.ts |  13 +-
 .../plugins/Automod/triggers/anyMessage.ts    |   8 +-
 .../Automod/triggers/availableTriggers.ts     |  45 +--
 backend/src/plugins/Automod/triggers/ban.ts   |  17 +-
 .../Automod/triggers/counterTrigger.ts        |  17 +-
 .../Automod/triggers/exampleTrigger.ts        |  14 +-
 backend/src/plugins/Automod/triggers/kick.ts  |  17 +-
 .../Automod/triggers/matchAttachmentType.ts   |  39 ++-
 .../plugins/Automod/triggers/matchInvites.ts  |  42 +--
 .../plugins/Automod/triggers/matchLinks.ts    |  60 ++--
 .../plugins/Automod/triggers/matchMimeType.ts |  39 ++-
 .../plugins/Automod/triggers/matchRegex.ts    |  42 +--
 .../plugins/Automod/triggers/matchWords.ts    |  49 ++-
 .../plugins/Automod/triggers/memberJoin.ts    |  19 +-
 .../Automod/triggers/memberJoinSpam.ts        |  16 +-
 .../plugins/Automod/triggers/memberLeave.ts   |   8 +-
 backend/src/plugins/Automod/triggers/mute.ts  |  17 +-
 backend/src/plugins/Automod/triggers/note.ts  |   7 +-
 .../src/plugins/Automod/triggers/roleAdded.ts |  13 +-
 .../plugins/Automod/triggers/roleRemoved.ts   |  13 +-
 .../plugins/Automod/triggers/threadArchive.ts |  13 +-
 .../plugins/Automod/triggers/threadCreate.ts  |   7 +-
 .../Automod/triggers/threadCreateSpam.ts      |  16 +-
 .../plugins/Automod/triggers/threadDelete.ts  |   7 +-
 .../Automod/triggers/threadUnarchive.ts       |  13 +-
 backend/src/plugins/Automod/triggers/unban.ts |   7 +-
 .../src/plugins/Automod/triggers/unmute.ts    |   7 +-
 backend/src/plugins/Automod/triggers/warn.ts  |  17 +-
 backend/src/plugins/Automod/types.ts          |  88 +++--
 .../plugins/BotControl/BotControlPlugin.ts    |   6 +-
 backend/src/plugins/BotControl/types.ts       |  21 +-
 backend/src/plugins/Cases/CasesPlugin.ts      |   8 +-
 backend/src/plugins/Cases/types.ts            |  26 +-
 backend/src/plugins/Censor/CensorPlugin.ts    |   7 +-
 backend/src/plugins/Censor/types.ts           |  36 +--
 .../ChannelArchiver/ChannelArchiverPlugin.ts  |   7 +-
 .../CompanionChannelsPlugin.ts                |   7 +-
 .../src/plugins/CompanionChannels/types.ts    |  29 +-
 .../plugins/ContextMenus/ContextMenuPlugin.ts |   5 +-
 .../src/plugins/ContextMenus/actions/mute.ts  |   4 +-
 backend/src/plugins/ContextMenus/types.ts     |  24 +-
 .../src/plugins/Counters/CountersPlugin.ts    |  68 +---
 .../getPrettyNameForCounterTrigger.ts         |   4 +-
 backend/src/plugins/Counters/types.ts         | 119 +++++--
 .../CustomEvents/CustomEventsPlugin.ts        |   5 +-
 .../CustomEvents/actions/addRoleAction.ts     |  14 +-
 .../CustomEvents/actions/createCaseAction.ts  |  19 +-
 .../actions/makeRoleMentionableAction.ts      |  14 +-
 .../actions/makeRoleUnmentionableAction.ts    |  11 +-
 .../CustomEvents/actions/messageAction.ts     |  13 +-
 .../actions/moveToVoiceChannelAction.ts       |  14 +-
 .../actions/setChannelPermissionOverrides.ts  |  25 +-
 backend/src/plugins/CustomEvents/types.ts     |  67 ++--
 .../GuildAccessMonitorPlugin.ts               |   5 +-
 .../GuildConfigReloaderPlugin.ts              |   5 +-
 .../GuildInfoSaver/GuildInfoSaverPlugin.ts    |   5 +-
 .../GuildMemberCachePlugin.ts                 |   6 +-
 .../InternalPoster/InternalPosterPlugin.ts    |   7 +-
 backend/src/plugins/InternalPoster/types.ts   |   6 -
 .../plugins/LocateUser/LocateUserPlugin.ts    |   7 +-
 backend/src/plugins/LocateUser/types.ts       |  11 +-
 backend/src/plugins/Logs/LogsPlugin.ts        |  18 +-
 .../Logs/logFunctions/logMessageDelete.ts     |   2 +-
 backend/src/plugins/Logs/types.ts             |  82 ++---
 .../MessageSaver/MessageSaverPlugin.ts        |   5 +-
 backend/src/plugins/MessageSaver/types.ts     |   9 +-
 .../plugins/ModActions/ModActionsPlugin.ts    |   8 +-
 backend/src/plugins/ModActions/types.ts       |  73 +++--
 backend/src/plugins/Mutes/MutesPlugin.ts      |   8 +-
 backend/src/plugins/Mutes/types.ts            |  39 ++-
 .../plugins/NameHistory/NameHistoryPlugin.ts  |   5 +-
 backend/src/plugins/NameHistory/types.ts      |   9 +-
 backend/src/plugins/Persist/PersistPlugin.ts  |   7 +-
 backend/src/plugins/Persist/types.ts          |  14 +-
 .../plugins/Phisherman/PhishermanPlugin.ts    |   6 +-
 backend/src/plugins/Phisherman/info.ts        |   4 +-
 backend/src/plugins/Phisherman/types.ts       |  10 +-
 .../PingableRoles/PingableRolesPlugin.ts      |   7 +-
 backend/src/plugins/PingableRoles/types.ts    |   9 +-
 backend/src/plugins/Post/PostPlugin.ts        |   7 +-
 backend/src/plugins/Post/types.ts             |   9 +-
 .../ReactionRoles/ReactionRolesPlugin.ts      |   7 +-
 backend/src/plugins/ReactionRoles/types.ts    |  24 +-
 .../src/plugins/Reminders/RemindersPlugin.ts  |   7 +-
 backend/src/plugins/Reminders/types.ts        |   9 +-
 .../plugins/RoleButtons/RoleButtonsPlugin.ts  |  41 +--
 backend/src/plugins/RoleButtons/info.ts       |   4 +-
 backend/src/plugins/RoleButtons/types.ts      | 126 +++++---
 .../plugins/RoleManager/RoleManagerPlugin.ts  |   6 +-
 backend/src/plugins/RoleManager/types.ts      |   7 +-
 backend/src/plugins/Roles/RolesPlugin.ts      |   9 +-
 backend/src/plugins/Roles/types.ts            |  13 +-
 .../SelfGrantableRolesPlugin.ts               |  22 +-
 .../src/plugins/SelfGrantableRoles/types.ts   |  38 ++-
 .../src/plugins/Slowmode/SlowmodePlugin.ts    |   7 +-
 backend/src/plugins/Slowmode/types.ts         |  15 +-
 backend/src/plugins/Spam/SpamPlugin.ts        |   7 +-
 backend/src/plugins/Spam/types.ts             |  53 +--
 .../src/plugins/Starboard/StarboardPlugin.ts  |  16 +-
 backend/src/plugins/Starboard/types.ts        |  46 ++-
 .../Starboard/util/preprocessStaticConfig.ts  |  12 -
 backend/src/plugins/Tags/TagsPlugin.ts        |  28 +-
 backend/src/plugins/Tags/types.ts             |  77 +++--
 .../src/plugins/Tags/util/findTagByName.ts    |   5 +-
 .../src/plugins/Tags/util/onMessageCreate.ts  |   9 +-
 .../plugins/TimeAndDate/TimeAndDatePlugin.ts  |   8 +-
 backend/src/plugins/TimeAndDate/types.ts      |  20 +-
 .../UsernameSaver/UsernameSaverPlugin.ts      |   6 +-
 backend/src/plugins/UsernameSaver/types.ts    |   4 +
 backend/src/plugins/Utility/UtilityPlugin.ts  |   8 +-
 backend/src/plugins/Utility/search.ts         |   3 +-
 backend/src/plugins/Utility/types.ts          |  61 ++--
 .../WelcomeMessage/WelcomeMessagePlugin.ts    |   7 +-
 backend/src/plugins/WelcomeMessage/types.ts   |  14 +-
 .../src/plugins/ZeppelinPluginBlueprint.ts    |   4 +-
 backend/src/types.ts                          |  31 +-
 backend/src/utils.test.ts                     |   6 +-
 backend/src/utils.ts                          | 302 ++++++------------
 backend/src/utils/iotsUtils.ts                |  17 -
 backend/src/utils/tColor.ts                   |  16 -
 backend/src/utils/tValidTimezone.ts           |  13 -
 backend/src/utils/typeUtils.ts                |   8 +
 backend/src/utils/zColor.ts                   |  15 +
 backend/src/utils/zValidTimezone.ts           |   8 +
 backend/src/validation.test.ts                |  40 ---
 backend/src/validatorUtils.ts                 | 140 --------
 package-lock.json                             |   7 +
 package.json                                  |   1 +
 161 files changed, 1450 insertions(+), 2105 deletions(-)
 delete mode 100644 backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
 delete mode 100644 backend/src/utils/iotsUtils.ts
 delete mode 100644 backend/src/utils/tColor.ts
 delete mode 100644 backend/src/utils/tValidTimezone.ts
 create mode 100644 backend/src/utils/zColor.ts
 create mode 100644 backend/src/utils/zValidTimezone.ts
 delete mode 100644 backend/src/validation.test.ts
 delete mode 100644 backend/src/validatorUtils.ts

diff --git a/backend/package-lock.json b/backend/package-lock.json
index b1d6d189..1f2f5992 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -22,7 +22,6 @@
         "express": "^4.17.0",
         "fp-ts": "^2.0.1",
         "humanize-duration": "^3.15.0",
-        "io-ts": "^2.0.0",
         "js-yaml": "^3.13.1",
         "knub": "^32.0.0-next.16",
         "knub-command-manager": "^9.1.0",
@@ -4975,14 +4974,6 @@
       "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
       "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
     },
-    "node_modules/io-ts": {
-      "version": "2.2.20",
-      "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz",
-      "integrity": "sha512-Rq2BsYmtwS5vVttie4rqrOCIfHCS9TgpRLFpKQCM1wZBBRY9nWVGmEvm2FnDbSE2un1UE39DvFpTR5UL47YDcA==",
-      "peerDependencies": {
-        "fp-ts": "^2.5.0"
-      }
-    },
     "node_modules/iota-array": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
diff --git a/backend/package.json b/backend/package.json
index 2ff53482..fbbe2ddc 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -43,7 +43,6 @@
     "express": "^4.17.0",
     "fp-ts": "^2.0.1",
     "humanize-duration": "^3.15.0",
-    "io-ts": "^2.0.0",
     "js-yaml": "^3.13.1",
     "knub": "^32.0.0-next.16",
     "knub-command-manager": "^9.1.0",
diff --git a/backend/src/commandTypes.ts b/backend/src/commandTypes.ts
index 47ad0405..9ace5312 100644
--- a/backend/src/commandTypes.ts
+++ b/backend/src/commandTypes.ts
@@ -17,6 +17,7 @@ import { createTypeHelper } from "knub-command-manager";
 import {
   channelMentionRegex,
   convertDelayStringToMS,
+  inputPatternToRegExp,
   isValidSnowflake,
   resolveMember,
   resolveUser,
@@ -26,7 +27,6 @@ import {
 } from "./utils";
 import { isValidTimezone } from "./utils/isValidTimezone";
 import { MessageTarget, resolveMessageTarget } from "./utils/resolveMessageTarget";
-import { inputPatternToRegExp } from "./validatorUtils";
 
 export const commandTypes = {
   ...messageCommandBaseTypeConverters,
diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts
index 3bb602a4..96e08ba3 100644
--- a/backend/src/configValidator.ts
+++ b/backend/src/configValidator.ts
@@ -1,9 +1,9 @@
-import { ConfigValidationError, PluginConfigManager } from "knub";
+import { PluginConfigManager } from "knub";
 import moment from "moment-timezone";
+import { ZodError } from "zod";
 import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
 import { guildPlugins } from "./plugins/availablePlugins";
-import { PartialZeppelinGuildConfigSchema, ZeppelinGuildConfig } from "./types";
-import { StrictValidationError, decodeAndValidateStrict } from "./validatorUtils";
+import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types";
 
 const pluginNameToPlugin = new Map<string, ZeppelinPlugin>();
 for (const plugin of guildPlugins) {
@@ -11,8 +11,10 @@ for (const plugin of guildPlugins) {
 }
 
 export async function validateGuildConfig(config: any): Promise<string | null> {
-  const validationResult = decodeAndValidateStrict(PartialZeppelinGuildConfigSchema, config);
-  if (validationResult instanceof StrictValidationError) return validationResult.getErrors();
+  const validationResult = zZeppelinGuildConfig.safeParse(config);
+  if (!validationResult.success) {
+    return validationResult.error.issues.join("\n");
+  }
 
   const guildConfig = config as ZeppelinGuildConfig;
 
@@ -41,8 +43,8 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
       try {
         await configManager.init();
       } catch (err) {
-        if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
-          return `${pluginName}: ${err.message}`;
+        if (err instanceof ZodError) {
+          return `${pluginName}: ${err.issues.join("\n")}`;
         }
 
         throw err;
diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts
index 8e6c6da2..a2f744d9 100644
--- a/backend/src/pluginUtils.ts
+++ b/backend/src/pluginUtils.ts
@@ -10,22 +10,18 @@ import {
   PermissionsBitField,
   TextBasedChannel,
 } from "discord.js";
-import * as t from "io-ts";
 import {
   AnyPluginData,
   CommandContext,
-  ConfigValidationError,
   ExtendedMatchParams,
   GuildPluginData,
-  PluginOverrideCriteria,
-  helpers,
+  helpers
 } from "knub";
 import { logger } from "./logger";
 import { isStaff } from "./staff";
 import { TZeppelinKnub } from "./types";
-import { errorMessage, successMessage, tNullable } from "./utils";
+import { errorMessage, successMessage } from "./utils";
 import { Tail } from "./utils/typeUtils";
-import { StrictValidationError, parseIoTsSchema } from "./validatorUtils";
 
 const { getMemberLevel } = helpers;
 
@@ -59,46 +55,6 @@ export async function hasPermission(
   return helpers.hasPermission(config, permission);
 }
 
-const PluginOverrideCriteriaType: t.Type<PluginOverrideCriteria<unknown>> = t.recursion(
-  "PluginOverrideCriteriaType",
-  () =>
-    t.partial({
-      channel: tNullable(t.union([t.string, t.array(t.string)])),
-      category: tNullable(t.union([t.string, t.array(t.string)])),
-      level: tNullable(t.union([t.string, t.array(t.string)])),
-      user: tNullable(t.union([t.string, t.array(t.string)])),
-      role: tNullable(t.union([t.string, t.array(t.string)])),
-
-      all: tNullable(t.array(PluginOverrideCriteriaType)),
-      any: tNullable(t.array(PluginOverrideCriteriaType)),
-      not: tNullable(PluginOverrideCriteriaType),
-
-      extra: t.unknown,
-    }),
-);
-
-export function strictValidationErrorToConfigValidationError(err: StrictValidationError) {
-  return new ConfigValidationError(
-    err
-      .getErrors()
-      .map((e) => e.toString())
-      .join("\n"),
-  );
-}
-
-export function makeIoTsConfigParser<Schema extends t.Type<any>>(schema: Schema): (input: unknown) => t.TypeOf<Schema> {
-  return (input: unknown) => {
-    try {
-      return parseIoTsSchema(schema, input);
-    } catch (err) {
-      if (err instanceof StrictValidationError) {
-        throw strictValidationErrorToConfigValidationError(err);
-      }
-      throw err;
-    }
-  };
-}
-
 export async function sendSuccessMessage(
   pluginData: AnyPluginData<any>,
   channel: TextBasedChannel,
diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
index 0a8e0438..d1273577 100644
--- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
+++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
@@ -1,11 +1,10 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { AutoDeletePluginType, ConfigSchema } from "./types";
+import { AutoDeletePluginType, zAutoDeleteConfig } from "./types";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageDelete } from "./util/onMessageDelete";
 import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk";
@@ -24,11 +23,11 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
     prettyName: "Auto-delete",
     description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
     configurationGuide: "Maximum deletion delay is currently 5 minutes",
-    configSchema: ConfigSchema,
+    configSchema: zAutoDeleteConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zAutoDeleteConfig.parse(input),
   defaultOptions,
 
   beforeLoad(pluginData) {
diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts
index 24389860..69100438 100644
--- a/backend/src/plugins/AutoDelete/types.ts
+++ b/backend/src/plugins/AutoDelete/types.ts
@@ -1,10 +1,10 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { SavedMessage } from "../../data/entities/SavedMessage";
-import { MINUTES, tDelayString } from "../../utils";
+import { MINUTES, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
+import z from "zod";
 
 export const MAX_DELAY = 5 * MINUTES;
 
@@ -13,14 +13,13 @@ export interface IDeletionQueueItem {
   message: SavedMessage;
 }
 
-export const ConfigSchema = t.type({
-  enabled: t.boolean,
-  delay: tDelayString,
+export const zAutoDeleteConfig = z.strictObject({
+  enabled: z.boolean(),
+  delay: zDelayString,
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutoDeletePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutoDeleteConfig>;
   state: {
     guildSavedMessages: GuildSavedMessages;
     guildLogs: GuildLogs;
diff --git a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
index 8ec1ecd7..9b01d538 100644
--- a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
+++ b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
@@ -1,14 +1,13 @@
 import { PluginOptions } from "knub";
 import { GuildAutoReactions } from "../../data/GuildAutoReactions";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd";
 import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd";
 import { AddReactionsEvt } from "./events/AddReactionsEvt";
-import { AutoReactionsPluginType, ConfigSchema } from "./types";
+import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types";
 
 const defaultOptions: PluginOptions<AutoReactionsPluginType> = {
   config: {
@@ -32,7 +31,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
     description: trimPluginDescription(`
       Allows setting up automatic reactions to all new messages on a channel
     `),
-    configSchema: ConfigSchema,
+    configSchema: zAutoReactionsConfig,
   },
 
   // prettier-ignore
@@ -40,7 +39,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
     LogsPlugin,
   ],
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zAutoReactionsConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/AutoReactions/types.ts b/backend/src/plugins/AutoReactions/types.ts
index a02c3c26..996fba8d 100644
--- a/backend/src/plugins/AutoReactions/types.ts
+++ b/backend/src/plugins/AutoReactions/types.ts
@@ -1,17 +1,16 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildAutoReactions } from "../../data/GuildAutoReactions";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { AutoReaction } from "../../data/entities/AutoReaction";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zAutoReactionsConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutoReactionsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutoReactionsConfig>;
   state: {
     logs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts
index ff4f01fd..9634c25d 100644
--- a/backend/src/plugins/Automod/AutomodPlugin.ts
+++ b/backend/src/plugins/Automod/AutomodPlugin.ts
@@ -1,4 +1,4 @@
-import { configUtils, CooldownManager } from "knub";
+import { CooldownManager } from "knub";
 import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
@@ -8,7 +8,6 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { MINUTES, SECONDS } from "../../utils";
 import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap";
 import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
 import { CountersPlugin } from "../Counters/CountersPlugin";
 import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -17,7 +16,6 @@ import { MutesPlugin } from "../Mutes/MutesPlugin";
 import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { availableActions } from "./actions/availableActions";
 import { AntiraidClearCmd } from "./commands/AntiraidClearCmd";
 import { SetAntiraidCmd } from "./commands/SetAntiraidCmd";
 import { ViewAntiraidCmd } from "./commands/ViewAntiraidCmd";
@@ -35,8 +33,7 @@ import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChang
 import { clearOldRecentActions } from "./functions/clearOldRecentActions";
 import { clearOldRecentSpam } from "./functions/clearOldRecentSpam";
 import { pluginInfo } from "./info";
-import { availableTriggers } from "./triggers/availableTriggers";
-import { AutomodPluginType, ConfigSchema } from "./types";
+import { AutomodPluginType, zAutomodConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -61,129 +58,6 @@ const defaultOptions = {
   ],
 };
 
-/**
- * Config preprocessor to set default values for triggers and perform extra validation
- * TODO: Separate input and output types
- */
-const configParser = (input: unknown) => {
-  const rules = (input as any).rules;
-  if (rules) {
-    // Loop through each rule
-    for (const [name, rule] of Object.entries(rules)) {
-      if (rule == null) {
-        delete rules[name];
-        continue;
-      }
-
-      rule["name"] = name;
-
-      // If the rule doesn't have an explicitly set "enabled" property, set it to true
-      if (rule["enabled"] == null) {
-        rule["enabled"] = true;
-      }
-
-      if (rule["allow_further_rules"] == null) {
-        rule["allow_further_rules"] = false;
-      }
-
-      if (rule["affects_bots"] == null) {
-        rule["affects_bots"] = false;
-      }
-
-      if (rule["affects_self"] == null) {
-        rule["affects_self"] = false;
-      }
-
-      // Loop through the rule's triggers
-      if (rule["triggers"]) {
-        for (const triggerObj of rule["triggers"]) {
-          for (const triggerName in triggerObj) {
-            if (!availableTriggers[triggerName]) {
-              throw new StrictValidationError([`Unknown trigger '${triggerName}' in rule '${rule["name"]}'`]);
-            }
-
-            const triggerBlueprint = availableTriggers[triggerName];
-
-            if (typeof triggerBlueprint.defaultConfig === "object" && triggerBlueprint.defaultConfig != null) {
-              triggerObj[triggerName] = configUtils.mergeConfig(
-                triggerBlueprint.defaultConfig,
-                triggerObj[triggerName] || {},
-              );
-            } else {
-              triggerObj[triggerName] = triggerObj[triggerName] || triggerBlueprint.defaultConfig;
-            }
-
-            if (triggerObj[triggerName].match_attachment_type) {
-              const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled;
-              const black = triggerObj[triggerName].match_attachment_type.blacklist_enabled;
-
-              if (white && black) {
-                throw new StrictValidationError([
-                  `Cannot have both blacklist and whitelist enabled at rule <${rule["name"]}/match_attachment_type>`,
-                ]);
-              } else if (!white && !black) {
-                throw new StrictValidationError([
-                  `Must have either blacklist or whitelist enabled at rule <${rule["name"]}/match_attachment_type>`,
-                ]);
-              }
-            }
-
-            if (triggerObj[triggerName].match_mime_type) {
-              const white = triggerObj[triggerName].match_mime_type.whitelist_enabled;
-              const black = triggerObj[triggerName].match_mime_type.blacklist_enabled;
-
-              if (white && black) {
-                throw new StrictValidationError([
-                  `Cannot have both blacklist and whitelist enabled at rule <${rule["name"]}/match_mime_type>`,
-                ]);
-              } else if (!white && !black) {
-                throw new StrictValidationError([
-                  `Must have either blacklist or whitelist enabled at rule <${rule["name"]}/match_mime_type>`,
-                ]);
-              }
-            }
-          }
-        }
-      }
-
-      if (rule["actions"]) {
-        for (const actionName in rule["actions"]) {
-          if (!availableActions[actionName]) {
-            throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule["name"]}'`]);
-          }
-
-          const actionBlueprint = availableActions[actionName];
-          const actionConfig = rule["actions"][actionName];
-
-          if (typeof actionConfig !== "object" || Array.isArray(actionConfig) || actionConfig == null) {
-            rule["actions"][actionName] = actionConfig;
-          } else {
-            rule["actions"][actionName] = configUtils.mergeConfig(actionBlueprint.defaultConfig, actionConfig);
-          }
-        }
-      }
-
-      // Enable logging of automod actions by default
-      if (rule["actions"]) {
-        for (const actionName in rule["actions"]) {
-          if (!availableActions[actionName]) {
-            throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule["name"]}'`]);
-          }
-        }
-
-        if (rule["actions"]["log"] == null) {
-          rule["actions"]["log"] = true;
-        }
-        if (rule["actions"]["clean"] && rule["actions"]["start_thread"]) {
-          throw new StrictValidationError([`Cannot have both clean and start_thread at rule '${rule["name"]}'`]);
-        }
-      }
-    }
-  }
-
-  return parseIoTsSchema(ConfigSchema, input);
-};
-
 export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
   name: "automod",
   showInDocs: true,
@@ -201,7 +75,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
   ],
 
   defaultOptions,
-  configParser,
+  configParser: (input) => zAutomodConfig.parse(input),
 
   customOverrideCriteriaFunctions: {
     antiraid_level: (pluginData, matchParams, value) => {
diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts
index 53b00005..89294728 100644
--- a/backend/src/plugins/Automod/actions/addRoles.ts
+++ b/backend/src/plugins/Automod/actions/addRoles.ts
@@ -1,6 +1,6 @@
 import { PermissionFlagsBits, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zSnowflake } from "../../../utils";
 import { canAssignRole } from "../../../utils/canAssignRole";
 import { getMissingPermissions } from "../../../utils/getMissingPermissions";
 import { missingPermissionError } from "../../../utils/missingPermissionError";
@@ -11,9 +11,10 @@ import { automodAction } from "../helpers";
 
 const p = PermissionFlagsBits;
 
+const configSchema = z.array(zSnowflake);
+
 export const AddRolesAction = automodAction({
-  configType: t.array(t.string),
-  defaultConfig: [],
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts
index c3a72dae..30ffe8fa 100644
--- a/backend/src/plugins/Automod/actions/addToCounter.ts
+++ b/backend/src/plugins/Automod/actions/addToCounter.ts
@@ -1,15 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { CountersPlugin } from "../../Counters/CountersPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
-export const AddToCounterAction = automodAction({
-  configType: t.type({
-    counter: t.string,
-    amount: t.number,
-  }),
+const configSchema = z.object({
+  counter: zBoundedCharacters(0, 100),
+  amount: z.number(),
+});
 
-  defaultConfig: {},
+export const AddToCounterAction = automodAction({
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const countersPlugin = pluginData.getPlugin(CountersPlugin);
diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts
index eb6f3348..1a767a0e 100644
--- a/backend/src/plugins/Automod/actions/alert.ts
+++ b/backend/src/plugins/Automod/actions/alert.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
+import z from "zod";
 import { LogType } from "../../../data/LogType";
 import {
   createTypedTemplateSafeValueContainer,
@@ -12,10 +12,12 @@ import {
   chunkMessageLines,
   isTruthy,
   messageLink,
-  tAllowedMentions,
-  tNormalizedNullOptional,
   validateAndParseMessageContent,
   verboseChannelMention,
+  zAllowedMentions,
+  zBoundedCharacters,
+  zNullishToUndefined,
+  zSnowflake
 } from "../../../utils";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
@@ -23,14 +25,14 @@ import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin"
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
-export const AlertAction = automodAction({
-  configType: t.type({
-    channel: t.string,
-    text: t.string,
-    allowed_mentions: tNormalizedNullOptional(tAllowedMentions),
-  }),
+const configSchema = z.object({
+  channel: zSnowflake,
+  text: zBoundedCharacters(1, 4000),
+  allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)),
+});
 
-  defaultConfig: {},
+export const AlertAction = automodAction({
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {
     const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
diff --git a/backend/src/plugins/Automod/actions/archiveThread.ts b/backend/src/plugins/Automod/actions/archiveThread.ts
index d94afdf7..e73cb3dd 100644
--- a/backend/src/plugins/Automod/actions/archiveThread.ts
+++ b/backend/src/plugins/Automod/actions/archiveThread.ts
@@ -1,11 +1,12 @@
 import { AnyThreadChannel } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { noop } from "../../../utils";
 import { automodAction } from "../helpers";
 
+const configSchema = z.strictObject({});
+
 export const ArchiveThreadAction = automodAction({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async apply({ pluginData, contexts }) {
     const threads = contexts
diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts
index 3f253bc0..dd148ca7 100644
--- a/backend/src/plugins/Automod/actions/availableActions.ts
+++ b/backend/src/plugins/Automod/actions/availableActions.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { AutomodActionBlueprint } from "../helpers";
 import { AddRolesAction } from "./addRoles";
 import { AddToCounterAction } from "./addToCounter";
@@ -19,7 +18,7 @@ import { SetSlowmodeAction } from "./setSlowmode";
 import { StartThreadAction } from "./startThread";
 import { WarnAction } from "./warn";
 
-export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
+export const availableActions = {
   clean: CleanAction,
   warn: WarnAction,
   mute: MuteAction,
@@ -38,25 +37,4 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
   start_thread: StartThreadAction,
   archive_thread: ArchiveThreadAction,
   change_perms: ChangePermsAction,
-};
-
-export const AvailableActions = t.type({
-  clean: CleanAction.configType,
-  warn: WarnAction.configType,
-  mute: MuteAction.configType,
-  kick: KickAction.configType,
-  ban: BanAction.configType,
-  alert: AlertAction.configType,
-  change_nickname: ChangeNicknameAction.configType,
-  log: LogAction.configType,
-  add_roles: AddRolesAction.configType,
-  remove_roles: RemoveRolesAction.configType,
-  set_antiraid_level: SetAntiraidLevelAction.configType,
-  reply: ReplyAction.configType,
-  add_to_counter: AddToCounterAction.configType,
-  set_counter: SetCounterAction.configType,
-  set_slowmode: SetSlowmodeAction.configType,
-  start_thread: StartThreadAction.configType,
-  archive_thread: ArchiveThreadAction.configType,
-  change_perms: ChangePermsAction.configType,
-});
+} satisfies Record<string, AutomodActionBlueprint<any>>;
diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts
index 9cd0fd86..dcc9563f 100644
--- a/backend/src/plugins/Automod/actions/ban.ts
+++ b/backend/src/plugins/Automod/actions/ban.ts
@@ -1,25 +1,23 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
+
+const configSchema = z.strictObject({
+  reason: zBoundedCharacters(0, 4000).nullable().default(null),
+  duration: zDelayString.nullable().default(null),
+  notify: zNotify.nullable().default(null),
+  notifyChannel: zSnowflake.nullable().default(null),
+  deleteMessageDays: z.number().nullable().default(null),
+  postInCaseLog: z.boolean().nullable().default(null),
+  hide_case: z.boolean().nullable().default(false),
+});
 
 export const BanAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    duration: tNullable(tDelayString),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    deleteMessageDays: tNullable(t.number),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
-  }),
-
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Kicked automatically";
diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts
index d63a3b60..ce7c610e 100644
--- a/backend/src/plugins/Automod/actions/changeNickname.ts
+++ b/backend/src/plugins/Automod/actions/changeNickname.ts
@@ -1,18 +1,16 @@
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zBoundedCharacters } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const ChangeNicknameAction = automodAction({
-  configType: t.union([
-    t.string,
-    t.type({
-      name: t.string,
+  configSchema: z.union([
+    zBoundedCharacters(0, 32),
+    z.strictObject({
+      name: zBoundedCharacters(0, 32),
     }),
   ]),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
 
diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index 1eaa6dd5..b5bc9686 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -1,7 +1,8 @@
 import { PermissionsBitField, PermissionsString } from "discord.js";
-import * as t from "io-ts";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { isValidSnowflake, noop, tNullable, tPartialDictionary } from "../../../utils";
+import { isValidSnowflake, keys, noop, zSnowflake } from "../../../utils";
 import {
   guildToTemplateSafeGuild,
   savedMessageToTemplateSafeSavedMessage,
@@ -59,16 +60,19 @@ const realToLegacyMap = Object.entries(legacyPermMap).reduce((map, pair) => {
   return map;
 }, {}) as Record<keyof typeof PermissionsBitField.Flags, keyof typeof legacyPermMap>;
 
+const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf<keyof typeof PermissionsBitField.Flags>;
+const legacyPermissionNames = keys(legacyPermMap) as U.ListOf<keyof typeof legacyPermMap>;
+const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const;
+
 export const ChangePermsAction = automodAction({
-  configType: t.type({
-    target: t.string,
-    channel: tNullable(t.string),
-    perms: tPartialDictionary(
-      t.union([t.keyof(PermissionsBitField.Flags), t.keyof(legacyPermMap)]),
-      tNullable(t.boolean),
+  configSchema: z.strictObject({
+    target: zSnowflake,
+    channel: zSnowflake.nullable().default(null),
+    perms: z.record(
+      z.enum(allPermissionNames),
+      z.boolean().nullable(),
     ),
   }),
-  defaultConfig: {},
 
   async apply({ pluginData, contexts, actionConfig }) {
     const user = contexts.find((c) => c.user)?.user;
diff --git a/backend/src/plugins/Automod/actions/clean.ts b/backend/src/plugins/Automod/actions/clean.ts
index 91bf37ac..5c66d370 100644
--- a/backend/src/plugins/Automod/actions/clean.ts
+++ b/backend/src/plugins/Automod/actions/clean.ts
@@ -1,12 +1,11 @@
 import { GuildTextBasedChannel, Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { LogType } from "../../../data/LogType";
 import { noop } from "../../../utils";
 import { automodAction } from "../helpers";
 
 export const CleanAction = automodAction({
-  configType: t.boolean,
-  defaultConfig: false,
+  configSchema: z.boolean().default(false),
 
   async apply({ pluginData, contexts, ruleName }) {
     const messageIdsToDeleteByChannelId: Map<string, string[]> = new Map();
diff --git a/backend/src/plugins/Automod/actions/exampleAction.ts b/backend/src/plugins/Automod/actions/exampleAction.ts
index 05bea0de..a43fc676 100644
--- a/backend/src/plugins/Automod/actions/exampleAction.ts
+++ b/backend/src/plugins/Automod/actions/exampleAction.ts
@@ -1,13 +1,12 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { automodAction } from "../helpers";
 
 export const ExampleAction = automodAction({
-  configType: t.type({
-    someValue: t.string,
+  configSchema: z.strictObject({
+    someValue: zBoundedCharacters(0, 1000),
   }),
 
-  defaultConfig: {},
-
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   async apply({ pluginData, contexts, actionConfig }) {
     // TODO: Everything
diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts
index 9e6a792d..5afba467 100644
--- a/backend/src/plugins/Automod/actions/kick.ts
+++ b/backend/src/plugins/Automod/actions/kick.ts
@@ -1,24 +1,20 @@
-import * as t from "io-ts";
-import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const KickAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Kicked automatically";
     const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts
index 82075a2e..dace25f1 100644
--- a/backend/src/plugins/Automod/actions/log.ts
+++ b/backend/src/plugins/Automod/actions/log.ts
@@ -1,11 +1,10 @@
-import * as t from "io-ts";
+import z from "zod";
 import { isTruthy, unique } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const LogAction = automodAction({
-  configType: t.boolean,
-  defaultConfig: true,
+  configSchema: z.boolean().default(true),
 
   async apply({ pluginData, contexts, ruleName, matchResult }) {
     const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts
index 3219c712..4a2a42ad 100644
--- a/backend/src/plugins/Automod/actions/mute.ts
+++ b/backend/src/plugins/Automod/actions/mute.ts
@@ -1,29 +1,25 @@
-import * as t from "io-ts";
+import z from "zod";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
-import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils";
+import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const MuteAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    duration: tNullable(tDelayString),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-    restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    duration: zDelayString.nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
+    restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {
     const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
     const reason = actionConfig.reason || "Muted automatically";
diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts
index a46d8262..8b065702 100644
--- a/backend/src/plugins/Automod/actions/removeRoles.ts
+++ b/backend/src/plugins/Automod/actions/removeRoles.ts
@@ -1,6 +1,6 @@
 import { PermissionFlagsBits, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zSnowflake } from "../../../utils";
 import { canAssignRole } from "../../../utils/canAssignRole";
 import { getMissingPermissions } from "../../../utils/getMissingPermissions";
 import { memberRolesLock } from "../../../utils/lockNameHelpers";
@@ -12,9 +12,7 @@ import { automodAction } from "../helpers";
 const p = PermissionFlagsBits;
 
 export const RemoveRolesAction = automodAction({
-  configType: t.array(t.string),
-
-  defaultConfig: [],
+  configSchema: z.array(zSnowflake).default([]),
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts
index 6239cee8..02ff9545 100644
--- a/backend/src/plugins/Automod/actions/reply.ts
+++ b/backend/src/plugins/Automod/actions/reply.ts
@@ -1,16 +1,16 @@
 import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import {
   convertDelayStringToMS,
   noop,
   renderRecursively,
-  tDelayString,
-  tMessageContent,
-  tNullable,
   unique,
   validateAndParseMessageContent,
   verboseChannelMention,
+  zBoundedCharacters,
+  zDelayString,
+  zMessageContent
 } from "../../../utils";
 import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
@@ -20,17 +20,15 @@ import { automodAction } from "../helpers";
 import { AutomodContext } from "../types";
 
 export const ReplyAction = automodAction({
-  configType: t.union([
-    t.string,
-    t.type({
-      text: tMessageContent,
-      auto_delete: tNullable(t.union([tDelayString, t.number])),
-      inline: tNullable(t.boolean),
+  configSchema: z.union([
+    zBoundedCharacters(0, 4000),
+    z.strictObject({
+      text: zMessageContent,
+      auto_delete: z.union([zDelayString, z.number()]).nullable().default(null),
+      inline: z.boolean().default(false),
     }),
   ]),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const contextsWithTextChannels = contexts
       .filter((c) => c.message?.channel_id)
diff --git a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
index ecddabd9..db2a6bef 100644
--- a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
+++ b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
@@ -1,11 +1,9 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import { zBoundedCharacters } from "../../../utils";
 import { setAntiraidLevel } from "../functions/setAntiraidLevel";
 import { automodAction } from "../helpers";
 
 export const SetAntiraidLevelAction = automodAction({
-  configType: tNullable(t.string),
-  defaultConfig: "",
+  configSchema: zBoundedCharacters(0, 100).nullable(),
 
   async apply({ pluginData, actionConfig }) {
     setAntiraidLevel(pluginData, actionConfig ?? null);
diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts
index 3286fb4b..dea4bdd6 100644
--- a/backend/src/plugins/Automod/actions/setCounter.ts
+++ b/backend/src/plugins/Automod/actions/setCounter.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { CountersPlugin } from "../../Counters/CountersPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const SetCounterAction = automodAction({
-  configType: t.type({
-    counter: t.string,
-    value: t.number,
+  configSchema: z.strictObject({
+    counter: zBoundedCharacters(0, 100),
+    value: z.number(),
   }),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const countersPlugin = pluginData.getPlugin(CountersPlugin);
     if (!countersPlugin.counterExists(actionConfig.counter)) {
diff --git a/backend/src/plugins/Automod/actions/setSlowmode.ts b/backend/src/plugins/Automod/actions/setSlowmode.ts
index 7b527f10..ecac0f21 100644
--- a/backend/src/plugins/Automod/actions/setSlowmode.ts
+++ b/backend/src/plugins/Automod/actions/setSlowmode.ts
@@ -1,19 +1,15 @@
 import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const SetSlowmodeAction = automodAction({
-  configType: t.type({
-    channels: t.array(t.string),
-    duration: tNullable(tDelayString),
+  configSchema: z.strictObject({
+    channels: z.array(zSnowflake),
+    duration: zDelayString.nullable().default("10s"),
   }),
 
-  defaultConfig: {
-    duration: "10s",
-  },
-
   async apply({ pluginData, actionConfig }) {
     const slowmodeMs = Math.max(actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : 0, 0);
 
diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 25b840e1..e0bc11a4 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -5,9 +5,9 @@ import {
   ThreadAutoArchiveDuration,
   ThreadChannel,
 } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { MINUTES, convertDelayStringToMS, noop, tDelayString, tNullable } from "../../../utils";
+import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils";
 import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { automodAction } from "../helpers";
 
@@ -19,18 +19,14 @@ const validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [
 ];
 
 export const StartThreadAction = automodAction({
-  configType: t.type({
-    name: tNullable(t.string),
-    auto_archive: tDelayString,
-    private: tNullable(t.boolean),
-    slowmode: tNullable(tDelayString),
-    limit_per_channel: tNullable(t.number),
+  configSchema: z.strictObject({
+    name: zBoundedCharacters(1, 100).nullable(),
+    auto_archive: zDelayString,
+    private: z.boolean().default(false),
+    slowmode: zDelayString.nullable().default(null),
+    limit_per_channel: z.number().nullable().default(5),
   }),
 
-  defaultConfig: {
-    limit_per_channel: 5,
-  },
-
   async apply({ pluginData, contexts, actionConfig }) {
     // check if the message still exists, we don't want to create threads for deleted messages
     const threads = contexts.filter((c) => {
diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts
index 59135cb2..e8c63340 100644
--- a/backend/src/plugins/Automod/actions/warn.ts
+++ b/backend/src/plugins/Automod/actions/warn.ts
@@ -1,24 +1,20 @@
-import * as t from "io-ts";
-import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const WarnAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Warned automatically";
     const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
index a1858d37..19364eee 100644
--- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
+++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
@@ -1,28 +1,27 @@
-import * as t from "io-ts";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { humanizeDurationShort } from "../../../humanizeDurationShort";
 import { getBaseUrl } from "../../../pluginUtils";
-import { convertDelayStringToMS, sorter, tDelayString, tNullable } from "../../../utils";
+import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { automodTrigger } from "../helpers";
 import { findRecentSpam } from "./findRecentSpam";
 import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
 import { getMessageSpamIdentifier } from "./getSpamIdentifier";
-
-const MessageSpamTriggerConfig = t.type({
-  amount: t.number,
-  within: tDelayString,
-  per_channel: tNullable(t.boolean),
-});
+import z from "zod";
 
 interface TMessageSpamMatchResultType {
   archiveId: string;
 }
 
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+  per_channel: z.boolean().optional(),
+});
+
 export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
   return automodTrigger<TMessageSpamMatchResultType>()({
-    configType: MessageSpamTriggerConfig,
-    defaultConfig: {},
+    configSchema,
 
     async match({ pluginData, context, triggerConfig }) {
       if (!context.message) {
diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts
index 5c5f55c7..d1c04d61 100644
--- a/backend/src/plugins/Automod/helpers.ts
+++ b/backend/src/plugins/Automod/helpers.ts
@@ -1,7 +1,7 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
 import { Awaitable } from "../../utils/typeUtils";
 import { AutomodContext, AutomodPluginType } from "./types";
+import z, { ZodTypeAny } from "zod";
 
 interface BaseAutomodTriggerMatchResult {
   extraContexts?: AutomodContext[];
@@ -31,21 +31,19 @@ type AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (m
   matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;
 }) => Awaitable<string>;
 
-export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra> {
-  configType: TConfigType;
-  defaultConfig: Partial<t.TypeOf<TConfigType>>;
-
-  match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
-  renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
+export interface AutomodTriggerBlueprint<TConfigSchema extends ZodTypeAny, TMatchResultExtra> {
+  configSchema: TConfigSchema;
+  match: AutomodTriggerMatchFn<z.output<TConfigSchema>, TMatchResultExtra>;
+  renderMatchInformation: AutomodTriggerRenderMatchInformationFn<z.output<TConfigSchema>, TMatchResultExtra>;
 }
 
-export function automodTrigger<TMatchResultExtra>(): <TConfigType extends t.Any>(
-  blueprint: AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>,
-) => AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>;
+export function automodTrigger<TMatchResultExtra>(): <TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>,
+) => AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>;
 
-export function automodTrigger<TConfigType extends t.Any>(
-  blueprint: AutomodTriggerBlueprint<TConfigType, unknown>,
-): AutomodTriggerBlueprint<TConfigType, unknown>;
+export function automodTrigger<TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodTriggerBlueprint<TConfigSchema, unknown>,
+): AutomodTriggerBlueprint<TConfigSchema, unknown>;
 
 export function automodTrigger(...args) {
   if (args.length) {
@@ -63,15 +61,13 @@ type AutomodActionApplyFn<TConfigType> = (meta: {
   matchResult: AutomodTriggerMatchResult;
 }) => Awaitable<void>;
 
-export interface AutomodActionBlueprint<TConfigType extends t.Any> {
-  configType: TConfigType;
-  defaultConfig: Partial<t.TypeOf<TConfigType>>;
-
-  apply: AutomodActionApplyFn<t.TypeOf<TConfigType>>;
+export interface AutomodActionBlueprint<TConfigSchema extends ZodTypeAny> {
+  configSchema: TConfigSchema;
+  apply: AutomodActionApplyFn<z.output<TConfigSchema>>;
 }
 
-export function automodAction<TConfigType extends t.Any>(
-  blueprint: AutomodActionBlueprint<TConfigType>,
-): AutomodActionBlueprint<TConfigType> {
+export function automodAction<TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodActionBlueprint<TConfigSchema>,
+): AutomodActionBlueprint<TConfigSchema> {
   return blueprint;
 }
diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts
index e0102459..a2071e21 100644
--- a/backend/src/plugins/Automod/info.ts
+++ b/backend/src/plugins/Automod/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zAutomodConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Automod",
@@ -100,5 +100,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
                     {matchSummary}
       ~~~
     `),
-  configSchema: ConfigSchema,
+  configSchema: zAutomodConfig,
 };
diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
index 7c6d17af..c879455f 100644
--- a/backend/src/plugins/Automod/triggers/antiraidLevel.ts
+++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
 import { automodTrigger } from "../helpers";
+import z from "zod";
 
 interface AntiraidLevelTriggerResult {}
 
-export const AntiraidLevelTrigger = automodTrigger<AntiraidLevelTriggerResult>()({
-  configType: t.type({
-    level: tNullable(t.string),
-  }),
+const configSchema = z.strictObject({
+  level: z.nullable(z.string().max(100)),
+});
 
-  defaultConfig: {},
+export const AntiraidLevelTrigger = automodTrigger<AntiraidLevelTriggerResult>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     if (!context.antiraid) {
diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts
index 5b611334..93ce0abc 100644
--- a/backend/src/plugins/Automod/triggers/anyMessage.ts
+++ b/backend/src/plugins/Automod/triggers/anyMessage.ts
@@ -1,14 +1,14 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
+import z from "zod";
 
 interface AnyMessageResultType {}
 
-export const AnyMessageTrigger = automodTrigger<AnyMessageResultType>()({
-  configType: t.type({}),
+const configSchema = z.strictObject({});
 
-  defaultConfig: {},
+export const AnyMessageTrigger = automodTrigger<AnyMessageResultType>()({
+  configSchema,
 
   async match({ context }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts
index 822e44ff..a2ac60fb 100644
--- a/backend/src/plugins/Automod/triggers/availableTriggers.ts
+++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { AutomodTriggerBlueprint } from "../helpers";
 import { AntiraidLevelTrigger } from "./antiraidLevel";
 import { AnyMessageTrigger } from "./anyMessage";
@@ -45,6 +44,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
   match_attachment_type: MatchAttachmentTypeTrigger,
   match_mime_type: MatchMimeTypeTrigger,
   member_join: MemberJoinTrigger,
+  member_leave: MemberLeaveTrigger,
   role_added: RoleAddedTrigger,
   role_removed: RoleRemovedTrigger,
 
@@ -76,46 +76,3 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
   thread_archive: ThreadArchiveTrigger,
   thread_unarchive: ThreadUnarchiveTrigger,
 };
-
-export const AvailableTriggers = t.type({
-  any_message: AnyMessageTrigger.configType,
-
-  match_words: MatchWordsTrigger.configType,
-  match_regex: MatchRegexTrigger.configType,
-  match_invites: MatchInvitesTrigger.configType,
-  match_links: MatchLinksTrigger.configType,
-  match_attachment_type: MatchAttachmentTypeTrigger.configType,
-  match_mime_type: MatchMimeTypeTrigger.configType,
-  member_join: MemberJoinTrigger.configType,
-  member_leave: MemberLeaveTrigger.configType,
-  role_added: RoleAddedTrigger.configType,
-  role_removed: RoleRemovedTrigger.configType,
-
-  message_spam: MessageSpamTrigger.configType,
-  mention_spam: MentionSpamTrigger.configType,
-  link_spam: LinkSpamTrigger.configType,
-  attachment_spam: AttachmentSpamTrigger.configType,
-  emoji_spam: EmojiSpamTrigger.configType,
-  line_spam: LineSpamTrigger.configType,
-  character_spam: CharacterSpamTrigger.configType,
-  member_join_spam: MemberJoinSpamTrigger.configType,
-  sticker_spam: StickerSpamTrigger.configType,
-  thread_create_spam: ThreadCreateSpamTrigger.configType,
-
-  counter_trigger: CounterTrigger.configType,
-
-  note: NoteTrigger.configType,
-  warn: WarnTrigger.configType,
-  mute: MuteTrigger.configType,
-  unmute: UnmuteTrigger.configType,
-  kick: KickTrigger.configType,
-  ban: BanTrigger.configType,
-  unban: UnbanTrigger.configType,
-
-  antiraid_level: AntiraidLevelTrigger.configType,
-
-  thread_create: ThreadCreateTrigger.configType,
-  thread_delete: ThreadDeleteTrigger.configType,
-  thread_archive: ThreadArchiveTrigger.configType,
-  thread_unarchive: ThreadUnarchiveTrigger.configType,
-});
diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts
index f8a4730b..f6df0fd6 100644
--- a/backend/src/plugins/Automod/triggers/ban.ts
+++ b/backend/src/plugins/Automod/triggers/ban.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface BanTriggerResultType {}
 
-export const BanTrigger = automodTrigger<BanTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const BanTrigger = automodTrigger<BanTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "ban") {
diff --git a/backend/src/plugins/Automod/triggers/counterTrigger.ts b/backend/src/plugins/Automod/triggers/counterTrigger.ts
index 58f21402..22de3df1 100644
--- a/backend/src/plugins/Automod/triggers/counterTrigger.ts
+++ b/backend/src/plugins/Automod/triggers/counterTrigger.ts
@@ -1,18 +1,17 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line
 interface CounterTriggerResult {}
 
-export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
-  configType: t.type({
-    counter: t.string,
-    trigger: t.string,
-    reverse: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  counter: z.string().max(100),
+  trigger: z.string().max(100),
+  reverse: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     if (!context.counterTrigger) {
diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts
index bf0880a9..e6005e07 100644
--- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts
+++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts
@@ -1,18 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ExampleMatchResultType {
   isBanana: boolean;
 }
 
-export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
-  configType: t.type({
-    allowedFruits: t.array(t.string),
-  }),
+const configSchema = z.strictObject({
+  allowedFruits: z.array(z.string().max(100)).max(50).default(["peach", "banana"]),
+});
 
-  defaultConfig: {
-    allowedFruits: ["peach", "banana"],
-  },
+export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);
diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts
index 163d6bf3..add6803e 100644
--- a/backend/src/plugins/Automod/triggers/kick.ts
+++ b/backend/src/plugins/Automod/triggers/kick.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface KickTriggerResultType {}
 
-export const KickTrigger = automodTrigger<KickTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const KickTrigger = automodTrigger<KickTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "kick") {
diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
index 659be65b..45f7ede7 100644
--- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
+++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
@@ -1,5 +1,5 @@
 import { escapeInlineCode, Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
@@ -8,20 +8,31 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    filetype_blacklist: t.array(t.string),
-    blacklist_enabled: t.boolean,
-    filetype_whitelist: t.array(t.string),
-    whitelist_enabled: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  filetype_blacklist: z.array(z.string().max(32)).max(255).default([]),
+  blacklist_enabled: z.boolean().default(false),
+  filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),
+  whitelist_enabled: z.boolean().default(false),
+}).transform((parsed, ctx) => {
+  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Cannot have both blacklist and whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Must have either blacklist or whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  return parsed;
+});
 
-  defaultConfig: {
-    filetype_blacklist: [],
-    blacklist_enabled: false,
-    filetype_whitelist: [],
-    whitelist_enabled: false,
-  },
+export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts
index 9e0b0c4e..4ea7c3cd 100644
--- a/backend/src/plugins/Automod/triggers/matchInvites.ts
+++ b/backend/src/plugins/Automod/triggers/matchInvites.ts
@@ -1,5 +1,5 @@
-import * as t from "io-ts";
-import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, tNullable } from "../../../utils";
+import z from "zod";
+import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
 import { automodTrigger } from "../helpers";
@@ -10,30 +10,22 @@ interface MatchResultType {
   invite?: GuildInvite;
 }
 
-export const MatchInvitesTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    include_guilds: tNullable(t.array(t.string)),
-    exclude_guilds: tNullable(t.array(t.string)),
-    include_invite_codes: tNullable(t.array(t.string)),
-    exclude_invite_codes: tNullable(t.array(t.string)),
-    allow_group_dm_invites: t.boolean,
-    match_messages: t.boolean,
-    match_embeds: t.boolean,
-    match_visible_names: t.boolean,
-    match_usernames: t.boolean,
-    match_nicknames: t.boolean,
-    match_custom_status: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  include_guilds: z.array(zSnowflake).max(255).optional(),
+  exclude_guilds: z.array(zSnowflake).max(255).optional(),
+  include_invite_codes: z.array(z.string().max(32)).max(255).optional(),
+  exclude_invite_codes: z.array(z.string().max(32)).max(255).optional(),
+  allow_group_dm_invites: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    allow_group_dm_invites: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+export const MatchInvitesTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts
index 5a8de7de..128b3cd1 100644
--- a/backend/src/plugins/Automod/triggers/matchLinks.ts
+++ b/backend/src/plugins/Automod/triggers/matchLinks.ts
@@ -1,11 +1,10 @@
 import { escapeInlineCode } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { allowTimeout } from "../../../RegExpRunner";
 import { phishermanDomainIsSafe } from "../../../data/Phisherman";
-import { getUrlsInString, tNullable } from "../../../utils";
+import { getUrlsInString, zRegex } from "../../../utils";
 import { mergeRegexes } from "../../../utils/mergeRegexes";
 import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex";
-import { TRegex } from "../../../validatorUtils";
 import { PhishermanPlugin } from "../../Phisherman/PhishermanPlugin";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
@@ -21,40 +20,29 @@ const regexCache = new WeakMap<any, RegExp[]>();
 
 const quickLinkCheck = /^https?:\/\//i;
 
-export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    include_domains: tNullable(t.array(t.string)),
-    exclude_domains: tNullable(t.array(t.string)),
-    include_subdomains: t.boolean,
-    include_words: tNullable(t.array(t.string)),
-    exclude_words: tNullable(t.array(t.string)),
-    include_regex: tNullable(t.array(TRegex)),
-    exclude_regex: tNullable(t.array(TRegex)),
-    phisherman: tNullable(
-      t.type({
-        include_suspected: tNullable(t.boolean),
-        include_verified: tNullable(t.boolean),
-      }),
-    ),
-    only_real_links: t.boolean,
-    match_messages: t.boolean,
-    match_embeds: t.boolean,
-    match_visible_names: t.boolean,
-    match_usernames: t.boolean,
-    match_nicknames: t.boolean,
-    match_custom_status: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  include_domains: z.array(z.string().max(255)).max(255).optional(),
+  exclude_domains: z.array(z.string().max(255)).max(255).optional(),
+  include_subdomains: z.boolean().default(true),
+  include_words: z.array(z.string().max(2000)).max(512).optional(),
+  exclude_words: z.array(z.string().max(2000)).max(512).optional(),
+  include_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
+  exclude_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
+  phisherman: z.strictObject({
+    include_suspected: z.boolean().optional(),
+    include_verified: z.boolean().optional(),
+  }).optional(),
+  only_real_links: z.boolean(),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(true),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    include_subdomains: true,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-    only_real_links: true,
-  },
+export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts
index 1af12471..8da9b2e3 100644
--- a/backend/src/plugins/Automod/triggers/matchMimeType.ts
+++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts
@@ -1,5 +1,5 @@
 import { escapeInlineCode } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
@@ -8,20 +8,31 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    mime_type_blacklist: t.array(t.string),
-    blacklist_enabled: t.boolean,
-    mime_type_whitelist: t.array(t.string),
-    whitelist_enabled: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]),
+  blacklist_enabled: z.boolean().default(false),
+  mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]),
+  whitelist_enabled: z.boolean().default(false),
+}).transform((parsed, ctx) => {
+  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Cannot have both blacklist and whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Must have either blacklist or whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  return parsed;
+});
 
-  defaultConfig: {
-    mime_type_blacklist: [],
-    blacklist_enabled: false,
-    mime_type_whitelist: [],
-    whitelist_enabled: false,
-  },
+export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig: trigger }) {
     if (!context.message) return;
diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts
index 7d011891..41df0396 100644
--- a/backend/src/plugins/Automod/triggers/matchRegex.ts
+++ b/backend/src/plugins/Automod/triggers/matchRegex.ts
@@ -1,9 +1,9 @@
-import * as t from "io-ts";
+import z from "zod";
 import { allowTimeout } from "../../../RegExpRunner";
+import { zRegex } from "../../../utils";
 import { mergeRegexes } from "../../../utils/mergeRegexes";
 import { normalizeText } from "../../../utils/normalizeText";
 import { stripMarkdown } from "../../../utils/stripMarkdown";
-import { TRegex } from "../../../validatorUtils";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
 import { automodTrigger } from "../helpers";
@@ -13,33 +13,23 @@ interface MatchResultType {
   type: MatchableTextType;
 }
 
+const configSchema = z.strictObject({
+  patterns: z.array(zRegex(z.string().max(2000))).max(512),
+  case_sensitive: z.boolean().default(false),
+  normalize: z.boolean().default(false),
+  strip_markdown: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
+
 const regexCache = new WeakMap<any, RegExp[]>();
 
 export const MatchRegexTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    patterns: t.array(TRegex),
-    case_sensitive: t.boolean,
-    normalize: t.boolean,
-    strip_markdown: t.boolean,
-    match_messages: t.boolean,
-    match_embeds: t.boolean,
-    match_visible_names: t.boolean,
-    match_usernames: t.boolean,
-    match_nicknames: t.boolean,
-    match_custom_status: t.boolean,
-  }),
-
-  defaultConfig: {
-    case_sensitive: false,
-    normalize: false,
-    strip_markdown: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts
index c5ca79fc..e8c275e1 100644
--- a/backend/src/plugins/Automod/triggers/matchWords.ts
+++ b/backend/src/plugins/Automod/triggers/matchWords.ts
@@ -1,5 +1,5 @@
 import escapeStringRegexp from "escape-string-regexp";
-import * as t from "io-ts";
+import z from "zod";
 import { normalizeText } from "../../../utils/normalizeText";
 import { stripMarkdown } from "../../../utils/stripMarkdown";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
@@ -13,37 +13,24 @@ interface MatchResultType {
 
 const regexCache = new WeakMap<any, RegExp[]>();
 
-export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    words: t.array(t.string),
-    case_sensitive: t.boolean,
-    only_full_words: t.boolean,
-    normalize: t.boolean,
-    loose_matching: t.boolean,
-    loose_matching_threshold: t.number,
-    strip_markdown: t.boolean,
-    match_messages: t.boolean,
-    match_embeds: t.boolean,
-    match_visible_names: t.boolean,
-    match_usernames: t.boolean,
-    match_nicknames: t.boolean,
-    match_custom_status: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  words: z.array(z.string().max(2000)).max(512),
+  case_sensitive: z.boolean().default(false),
+  only_full_words: z.boolean().default(true),
+  normalize: z.boolean().default(false),
+  loose_matching: z.boolean().default(false),
+  loose_matching_threshold: z.number().int().default(4),
+  strip_markdown: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    case_sensitive: false,
-    only_full_words: true,
-    normalize: false,
-    loose_matching: false,
-    loose_matching_threshold: 4,
-    strip_markdown: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/memberJoin.ts b/backend/src/plugins/Automod/triggers/memberJoin.ts
index f62d87aa..af9a1fae 100644
--- a/backend/src/plugins/Automod/triggers/memberJoin.ts
+++ b/backend/src/plugins/Automod/triggers/memberJoin.ts
@@ -1,17 +1,14 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
-export const MemberJoinTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    only_new: t.boolean,
-    new_threshold: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  only_new: z.boolean().default(false),
+  new_threshold: zDelayString.default("1h"),
+});
 
-  defaultConfig: {
-    only_new: false,
-    new_threshold: "1h",
-  },
+export const MemberJoinTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
index c55c8895..e346a8d3 100644
--- a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
+++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
@@ -1,18 +1,18 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { findRecentSpam } from "../functions/findRecentSpam";
 import { getMatchingRecentActions } from "../functions/getMatchingRecentActions";
 import { sumRecentActionCounts } from "../functions/sumRecentActionCounts";
 import { automodTrigger } from "../helpers";
 
-export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    amount: t.number,
-    within: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+});
 
-  defaultConfig: {},
+export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/memberLeave.ts b/backend/src/plugins/Automod/triggers/memberLeave.ts
index c5a25033..fa9f37a8 100644
--- a/backend/src/plugins/Automod/triggers/memberLeave.ts
+++ b/backend/src/plugins/Automod/triggers/memberLeave.ts
@@ -1,10 +1,10 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
-export const MemberLeaveTrigger = automodTrigger<unknown>()({
-  configType: t.type({}),
+const configSchema = z.strictObject({});
 
-  defaultConfig: {},
+export const MemberLeaveTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ context }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts
index 144c30f2..dcaf96c5 100644
--- a/backend/src/plugins/Automod/triggers/mute.ts
+++ b/backend/src/plugins/Automod/triggers/mute.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface MuteTriggerResultType {}
 
-export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "mute") {
diff --git a/backend/src/plugins/Automod/triggers/note.ts b/backend/src/plugins/Automod/triggers/note.ts
index c6d11be7..eeaa2c27 100644
--- a/backend/src/plugins/Automod/triggers/note.ts
+++ b/backend/src/plugins/Automod/triggers/note.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface NoteTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const NoteTrigger = automodTrigger<NoteTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "note") {
diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index dc62f163..47027b6b 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import z from "zod";
+import { renderUserUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -8,10 +8,13 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
-  configType: t.union([t.string, t.array(t.string)]),
+const configSchema = z.union([
+  zSnowflake,
+  z.array(zSnowflake).max(255),
+]).default([]);
 
-  defaultConfig: "",
+export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
+  configSchema,
 
   async match({ triggerConfig, context, pluginData }) {
     if (!context.member || !context.rolesChanged || context.rolesChanged.added!.length === 0) {
diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts
index 65624827..26caf227 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import z from "zod";
+import { renderUserUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -8,10 +8,13 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
-  configType: t.union([t.string, t.array(t.string)]),
+const configSchema = z.union([
+  zSnowflake,
+  z.array(zSnowflake).max(255),
+]).default([]);
 
-  defaultConfig: "",
+export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
+  configSchema,
 
   async match({ triggerConfig, context, pluginData }) {
     if (!context.member || !context.rolesChanged || context.rolesChanged.removed!.length === 0) {
diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts
index 0e65b10a..87cc1b2a 100644
--- a/backend/src/plugins/Automod/triggers/threadArchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadArchive.ts
@@ -1,6 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadArchiveResult {
@@ -11,12 +10,12 @@ interface ThreadArchiveResult {
   matchedThreadOwner: User | undefined;
 }
 
-export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
-  configType: t.type({
-    locked: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  locked: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.threadChange?.archived) {
diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts
index 7b8aca71..ba5553de 100644
--- a/backend/src/plugins/Automod/triggers/threadCreate.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreate.ts
@@ -1,5 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadCreateResult {
@@ -10,9 +10,10 @@ interface ThreadCreateResult {
   matchedThreadOwner: User | undefined;
 }
 
+const configSchema = z.strictObject({});
+
 export const ThreadCreateTrigger = automodTrigger<ThreadCreateResult>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (!context.threadChange?.created) {
diff --git a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
index b1d02f47..a4352c11 100644
--- a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
@@ -1,18 +1,18 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { findRecentSpam } from "../functions/findRecentSpam";
 import { getMatchingRecentActions } from "../functions/getMatchingRecentActions";
 import { sumRecentActionCounts } from "../functions/sumRecentActionCounts";
 import { automodTrigger } from "../helpers";
 
-export const ThreadCreateSpamTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    amount: t.number,
-    within: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+});
 
-  defaultConfig: {},
+export const ThreadCreateSpamTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig }) {
     if (!context.threadChange?.created) {
diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts
index 489b5b4c..fc538cf8 100644
--- a/backend/src/plugins/Automod/triggers/threadDelete.ts
+++ b/backend/src/plugins/Automod/triggers/threadDelete.ts
@@ -1,5 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadDeleteResult {
@@ -10,9 +10,10 @@ interface ThreadDeleteResult {
   matchedThreadOwner: User | undefined;
 }
 
+const configSchema = z.strictObject({});
+
 export const ThreadDeleteTrigger = automodTrigger<ThreadDeleteResult>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (!context.threadChange?.deleted) {
diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
index f6047f48..3d4a3cdf 100644
--- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
@@ -1,6 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadUnarchiveResult {
@@ -11,12 +10,12 @@ interface ThreadUnarchiveResult {
   matchedThreadOwner: User | undefined;
 }
 
-export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
-  configType: t.type({
-    locked: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  locked: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.threadChange?.unarchived) {
diff --git a/backend/src/plugins/Automod/triggers/unban.ts b/backend/src/plugins/Automod/triggers/unban.ts
index c653f615..25f24ec9 100644
--- a/backend/src/plugins/Automod/triggers/unban.ts
+++ b/backend/src/plugins/Automod/triggers/unban.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface UnbanTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const UnbanTrigger = automodTrigger<UnbanTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "unban") {
diff --git a/backend/src/plugins/Automod/triggers/unmute.ts b/backend/src/plugins/Automod/triggers/unmute.ts
index fbd946c6..f9695ef0 100644
--- a/backend/src/plugins/Automod/triggers/unmute.ts
+++ b/backend/src/plugins/Automod/triggers/unmute.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface UnmuteTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const UnmuteTrigger = automodTrigger<UnmuteTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "unmute") {
diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts
index 5c350dad..3586e82d 100644
--- a/backend/src/plugins/Automod/triggers/warn.ts
+++ b/backend/src/plugins/Automod/triggers/warn.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface WarnTriggerResultType {}
 
-export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "warn") {
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index bd3f05ee..59a09a33 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -1,6 +1,6 @@
 import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
@@ -8,39 +8,81 @@ import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { SavedMessage } from "../../data/entities/SavedMessage";
-import { tNullable } from "../../utils";
+import { entries, zBoundedRecord, zDelayString } from "../../utils";
 import { CounterEvents } from "../Counters/types";
 import { ModActionType, ModActionsEvents } from "../ModActions/types";
 import { MutesEvents } from "../Mutes/types";
-import { AvailableActions } from "./actions/availableActions";
+import { availableActions } from "./actions/availableActions";
 import { RecentActionType } from "./constants";
-import { AvailableTriggers } from "./triggers/availableTriggers";
+import { availableTriggers } from "./triggers/availableTriggers";
 
 import Timeout = NodeJS.Timeout;
 
-export const Rule = t.type({
-  enabled: t.boolean,
-  name: t.string,
-  presets: tNullable(t.array(t.string)),
-  affects_bots: t.boolean,
-  affects_self: t.boolean,
-  triggers: t.array(t.partial(AvailableTriggers.props)),
-  actions: t.partial(AvailableActions.props),
-  cooldown: tNullable(t.string),
-  allow_further_rules: t.boolean,
-});
-export type TRule = t.TypeOf<typeof Rule>;
+export type ZTriggersMapHelper = {
+  [TriggerName in keyof typeof availableTriggers]: typeof availableTriggers[TriggerName]["configSchema"];
+};
+const zTriggersMap = z.strictObject(entries(availableTriggers).reduce((map, [triggerName, trigger]) => {
+  map[triggerName] = trigger.configSchema;
+  return map;
+}, {} as ZTriggersMapHelper)).partial();
 
-export const ConfigSchema = t.type({
-  rules: t.record(t.string, Rule),
-  antiraid_levels: t.array(t.string),
-  can_set_antiraid: t.boolean,
-  can_view_antiraid: t.boolean,
+type ZActionsMapHelper = {
+  [ActionName in keyof typeof availableActions]: typeof availableActions[ActionName]["configSchema"];
+};
+const zActionsMap = z.strictObject(entries(availableActions).reduce((map, [actionName, action]) => {
+  // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper
+  map[actionName] = action.configSchema;
+  return map;
+}, {} as ZActionsMapHelper)).partial();
+
+const zRule = z.strictObject({
+  enabled: z.boolean().default(true),
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Automod rules must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  presets: z.array(z.string().max(100)).max(25).default([]),
+  affects_bots: z.boolean().default(false),
+  affects_self: z.boolean().default(false),
+  cooldown: zDelayString.nullable().default(null),
+  allow_further_rules: z.boolean().default(false),
+  triggers: z.array(zTriggersMap),
+  actions: zActionsMap.refine(
+    (v) => ! (v.clean && v.start_thread),
+    {
+      message: "Cannot have both clean and start_thread active at the same time",
+    }
+  ),
+});
+export type TRule = z.infer<typeof zRule>;
+
+export const zNotify = z.union([
+  z.literal("dm"),
+  z.literal("channel"),
+]);
+
+export const zAutomodConfig = z.strictObject({
+  rules: zBoundedRecord(
+    z.record(z.string().max(100), zRule),
+    0,
+    100,
+  ),
+  antiraid_levels: z.array(z.string().max(100)).max(10),
+  can_set_antiraid: z.boolean(),
+  can_view_antiraid: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutomodPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutomodConfig>;
 
   customOverrideCriteria: {
     antiraid_level?: string;
diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts
index 76478ca2..653e8def 100644
--- a/backend/src/plugins/BotControl/BotControlPlugin.ts
+++ b/backend/src/plugins/BotControl/BotControlPlugin.ts
@@ -3,7 +3,7 @@ import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
 import { Configs } from "../../data/Configs";
 import { GuildArchives } from "../../data/GuildArchives";
-import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils";
+import { sendSuccessMessage } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
 import { getActiveReload, resetActiveReload } from "./activeReload";
 import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd";
@@ -22,7 +22,7 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
 import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
 import { RestPerformanceCmd } from "./commands/RestPerformanceCmd";
 import { ServersCmd } from "./commands/ServersCmd";
-import { BotControlPluginType, ConfigSchema } from "./types";
+import { BotControlPluginType, zBotControlConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -37,7 +37,7 @@ const defaultOptions = {
 
 export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
   name: "bot_control",
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zBotControlConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts
index 6f4139f8..1c1ccea6 100644
--- a/backend/src/plugins/BotControl/types.ts
+++ b/backend/src/plugins/BotControl/types.ts
@@ -1,23 +1,22 @@
-import * as t from "io-ts";
 import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from "knub";
+import z from "zod";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
 import { Configs } from "../../data/Configs";
 import { GuildArchives } from "../../data/GuildArchives";
-import { tNullable } from "../../utils";
+import { zBoundedCharacters } from "../../utils";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
-  can_eligible: t.boolean,
-  can_performance: t.boolean,
-  can_add_server_from_invite: t.boolean,
-  can_list_dashboard_perms: t.boolean,
-  update_cmd: tNullable(t.string),
+export const zBotControlConfig = z.strictObject({
+  can_use: z.boolean(),
+  can_eligible: z.boolean(),
+  can_performance: z.boolean(),
+  can_add_server_from_invite: z.boolean(),
+  can_list_dashboard_perms: z.boolean(),
+  update_cmd: zBoundedCharacters(0, 2000).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface BotControlPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zBotControlConfig>;
   state: {
     archives: GuildArchives;
     allowedGuilds: AllowedGuilds;
diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts
index 45f63c79..9e6df53a 100644
--- a/backend/src/plugins/Cases/CasesPlugin.ts
+++ b/backend/src/plugins/Cases/CasesPlugin.ts
@@ -3,7 +3,7 @@ import { Case } from "../../data/entities/Case";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
@@ -16,7 +16,7 @@ import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUser
 import { getRecentCasesByMod } from "./functions/getRecentCasesByMod";
 import { getTotalCasesByMod } from "./functions/getTotalCasesByMod";
 import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel";
-import { CaseArgs, CaseNoteArgs, CasesPluginType, ConfigSchema } from "./types";
+import { CaseArgs, CaseNoteArgs, CasesPluginType, zCasesConfig } from "./types";
 
 // The `any` cast here is to prevent TypeScript from locking up from the circular dependency
 function getLogsPlugin(): Promise<any> {
@@ -42,11 +42,11 @@ export const CasesPlugin = zeppelinGuildPlugin<CasesPluginType>()({
     description: trimPluginDescription(`
       This plugin contains basic configuration for cases created by other plugins
     `),
-    configSchema: ConfigSchema,
+    configSchema: zCasesConfig,
   },
 
   dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCasesConfig.parse(input),
   defaultOptions,
 
   public: {
diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts
index 9ec1a371..e25b7ed0 100644
--- a/backend/src/plugins/Cases/types.ts
+++ b/backend/src/plugins/Cases/types.ts
@@ -1,24 +1,26 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { CaseNameToType, CaseTypes } from "../../data/CaseTypes";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tDelayString, tNullable, tPartialDictionary } from "../../utils";
-import { tColor } from "../../utils/tColor";
+import { keys, zBoundedCharacters, zDelayString, zSnowflake } from "../../utils";
+import { zColor } from "../../utils/zColor";
 
-export const ConfigSchema = t.type({
-  log_automatic_actions: t.boolean,
-  case_log_channel: tNullable(t.string),
-  show_relative_times: t.boolean,
-  relative_time_cutoff: tDelayString,
-  case_colors: tNullable(tPartialDictionary(t.keyof(CaseNameToType), tColor)),
-  case_icons: tNullable(tPartialDictionary(t.keyof(CaseNameToType), t.string)),
+const caseKeys = keys(CaseNameToType) as U.ListOf<keyof typeof CaseNameToType>;
+
+export const zCasesConfig = z.strictObject({
+  log_automatic_actions: z.boolean(),
+  case_log_channel: zSnowflake.nullable(),
+  show_relative_times: z.boolean(),
+  relative_time_cutoff: zDelayString.default("1w"),
+  case_colors: z.record(z.enum(caseKeys), zColor).nullable(),
+  case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 32)).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CasesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCasesConfig>;
   state: {
     logs: GuildLogs;
     cases: GuildCases;
diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts
index 61991e12..5764d396 100644
--- a/backend/src/plugins/Censor/CensorPlugin.ts
+++ b/backend/src/plugins/Censor/CensorPlugin.ts
@@ -1,12 +1,11 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { CensorPluginType, ConfigSchema } from "./types";
+import { CensorPluginType, zCensorConfig } from "./types";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageUpdate } from "./util/onMessageUpdate";
 
@@ -54,11 +53,11 @@ export const CensorPlugin = zeppelinGuildPlugin<CensorPluginType>()({
       For more advanced filtering, check out the Automod plugin!
     `),
     legacy: true,
-    configSchema: ConfigSchema,
+    configSchema: zCensorConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCensorConfig.parse(input),
   defaultOptions,
 
   beforeLoad(pluginData) {
diff --git a/backend/src/plugins/Censor/types.ts b/backend/src/plugins/Censor/types.ts
index 5a1984ce..c1fcd8df 100644
--- a/backend/src/plugins/Censor/types.ts
+++ b/backend/src/plugins/Censor/types.ts
@@ -1,30 +1,28 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
-import { TRegex } from "../../validatorUtils";
+import { zBoundedCharacters, zRegex, zSnowflake } from "../../utils";
 
-export const ConfigSchema = t.type({
-  filter_zalgo: t.boolean,
-  filter_invites: t.boolean,
-  invite_guild_whitelist: tNullable(t.array(t.string)),
-  invite_guild_blacklist: tNullable(t.array(t.string)),
-  invite_code_whitelist: tNullable(t.array(t.string)),
-  invite_code_blacklist: tNullable(t.array(t.string)),
-  allow_group_dm_invites: t.boolean,
-  filter_domains: t.boolean,
-  domain_whitelist: tNullable(t.array(t.string)),
-  domain_blacklist: tNullable(t.array(t.string)),
-  blocked_tokens: tNullable(t.array(t.string)),
-  blocked_words: tNullable(t.array(t.string)),
-  blocked_regex: tNullable(t.array(TRegex)),
+export const zCensorConfig = z.strictObject({
+  filter_zalgo: z.boolean(),
+  filter_invites: z.boolean(),
+  invite_guild_whitelist: z.array(zSnowflake).nullable(),
+  invite_guild_blacklist: z.array(zSnowflake).nullable(),
+  invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable(),
+  invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable(),
+  allow_group_dm_invites: z.boolean(),
+  filter_domains: z.boolean(),
+  domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable(),
+  domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable(),
+  blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable(),
+  blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable(),
+  blocked_regex: z.array(zRegex(z.string().max(1000))).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CensorPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCensorConfig>;
   state: {
     serverLogs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
index b4467819..615d3467 100644
--- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
+++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
@@ -1,18 +1,15 @@
-import * as t from "io-ts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
+import z from "zod";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd";
 import { ChannelArchiverPluginType } from "./types";
 
-const ConfigSchema = t.type({});
-
 export const ChannelArchiverPlugin = zeppelinGuildPlugin<ChannelArchiverPluginType>()({
   name: "channel_archiver",
   showInDocs: false,
 
   dependencies: () => [TimeAndDatePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   // prettier-ignore
   messageCommands: [
diff --git a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
index bd05d800..4e7987a7 100644
--- a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
+++ b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
@@ -1,11 +1,10 @@
 import { CooldownManager } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { VoiceStateUpdateEvt } from "./events/VoiceStateUpdateEvt";
-import { CompanionChannelsPluginType, ConfigSchema } from "./types";
+import { CompanionChannelsPluginType, zCompanionChannelsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -23,11 +22,11 @@ export const CompanionChannelsPlugin = zeppelinGuildPlugin<CompanionChannelsPlug
       Once set up, any time a user joins one of the specified voice channels,
       they'll get channel permissions applied to them for the text channels.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zCompanionChannelsConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCompanionChannelsConfig.parse(input),
   defaultOptions,
 
   events: [VoiceStateUpdateEvt],
diff --git a/backend/src/plugins/CompanionChannels/types.ts b/backend/src/plugins/CompanionChannels/types.ts
index 46a8a45f..7cee4b3a 100644
--- a/backend/src/plugins/CompanionChannels/types.ts
+++ b/backend/src/plugins/CompanionChannels/types.ts
@@ -1,28 +1,23 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tNullable } from "../../utils";
+import { zBoundedCharacters, zSnowflake } from "../../utils";
 
-// Permissions using these numbers: https://abal.moe/Eris/docs/reference (add all allowed/denied ones up)
-export const CompanionChannelOpts = t.type({
-  voice_channel_ids: t.array(t.string),
-  text_channel_ids: t.array(t.string),
-  permissions: t.number,
-  enabled: tNullable(t.boolean),
+export const zCompanionChannelOpts = z.strictObject({
+  voice_channel_ids: z.array(zSnowflake),
+  text_channel_ids: z.array(zSnowflake),
+  // See https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
+  permissions: z.number(),
+  enabled: z.boolean().nullable().default(true),
 });
-export type TCompanionChannelOpts = t.TypeOf<typeof CompanionChannelOpts>;
+export type TCompanionChannelOpts = z.infer<typeof zCompanionChannelOpts>;
 
-export const ConfigSchema = t.type({
-  entries: t.record(t.string, CompanionChannelOpts),
+export const zCompanionChannelsConfig = z.strictObject({
+  entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export interface ICompanionChannelMap {
-  [channelId: string]: TCompanionChannelOpts;
-}
 
 export interface CompanionChannelsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCompanionChannelsConfig>;
   state: {
     errorCooldownManager: CooldownManager;
     serverLogs: GuildLogs;
diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
index c41e8c09..57d1a763 100644
--- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
+++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
@@ -1,12 +1,11 @@
 import { PluginOptions } from "knub";
 import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { MutesPlugin } from "../Mutes/MutesPlugin";
 import { UtilityPlugin } from "../Utility/UtilityPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ContextClickedEvt } from "./events/ContextClickedEvt";
-import { ConfigSchema, ContextMenuPluginType } from "./types";
+import { ContextMenuPluginType, zContextMenusConfig } from "./types";
 import { loadAllCommands } from "./utils/loadAllCommands";
 
 const defaultOptions: PluginOptions<ContextMenuPluginType> = {
@@ -37,7 +36,7 @@ export const ContextMenuPlugin = zeppelinGuildPlugin<ContextMenuPluginType>()({
   showInDocs: false,
 
   dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zContextMenusConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts
index 7fe2f5d5..556ee248 100644
--- a/backend/src/plugins/ContextMenus/actions/mute.ts
+++ b/backend/src/plugins/ContextMenus/actions/mute.ts
@@ -45,9 +45,9 @@ export async function muteAction(
   try {
     const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs });
 
-    const muteMessage = `Muted **${result.case.user_name}** ${
+    const muteMessage = `Muted **${result.case!.user_name}** ${
       durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely"
-    } (Case #${result.case.case_number}) (user notified via ${
+    } (Case #${result.case!.case_number}) (user notified via ${
       result.notifyResult.method ?? "dm"
     })\nPlease update the new case with the \`update\` command`;
 
diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts
index 02c4a29c..5dbcd9ce 100644
--- a/backend/src/plugins/ContextMenus/types.ts
+++ b/backend/src/plugins/ContextMenus/types.ts
@@ -1,22 +1,20 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
-
-  user_muteindef: t.boolean,
-  user_mute1d: t.boolean,
-  user_mute1h: t.boolean,
-  user_info: t.boolean,
-  message_clean10: t.boolean,
-  message_clean25: t.boolean,
-  message_clean50: t.boolean,
+export const zContextMenusConfig = z.strictObject({
+  can_use: z.boolean(),
+  user_muteindef: z.boolean(),
+  user_mute1d: z.boolean(),
+  user_mute1h: z.boolean(),
+  user_info: z.boolean(),
+  message_clean10: z.boolean(),
+  message_clean25: z.boolean(),
+  message_clean50: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface ContextMenuPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zContextMenusConfig>;
   state: {
     contextMenuLinks: GuildContextMenuLinks;
   };
diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts
index 14e3432e..85be2cf5 100644
--- a/backend/src/plugins/Counters/CountersPlugin.ts
+++ b/backend/src/plugins/Counters/CountersPlugin.ts
@@ -1,15 +1,12 @@
 import { EventEmitter } from "events";
 import { PluginOptions } from "knub";
-import {
-  buildCounterConditionString,
-  CounterTrigger,
-  getReverseCounterComparisonOp,
-  parseCounterConditionString,
-} from "../../data/entities/CounterTrigger";
 import { GuildCounters } from "../../data/GuildCounters";
+import {
+  CounterTrigger,
+  parseCounterConditionString
+} from "../../data/entities/CounterTrigger";
 import { mapToPublicFn } from "../../pluginUtils";
-import { convertDelayStringToMS, MINUTES } from "../../utils";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
+import { MINUTES, convertDelayStringToMS, values } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { AddCounterCmd } from "./commands/AddCounterCmd";
 import { CountersListCmd } from "./commands/CountersListCmd";
@@ -25,10 +22,8 @@ import { getPrettyNameForCounterTrigger } from "./functions/getPrettyNameForCoun
 import { offCounterEvent } from "./functions/offCounterEvent";
 import { onCounterEvent } from "./functions/onCounterEvent";
 import { setCounterValue } from "./functions/setCounterValue";
-import { ConfigSchema, CountersPluginType, TTrigger } from "./types";
+import { CountersPluginType, zCountersConfig } from "./types";
 
-const MAX_COUNTERS = 5;
-const MAX_TRIGGERS_PER_COUNTER = 5;
 const DECAY_APPLY_INTERVAL = 5 * MINUTES;
 
 const defaultOptions: PluginOptions<CountersPluginType> = {
@@ -72,50 +67,12 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()({
     description:
       "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number",
     configurationGuide: "See <a href='/docs/setup-guides/counters'>Counters setup guide</a>",
-    configSchema: ConfigSchema,
+    configSchema: zCountersConfig,
   },
 
   defaultOptions,
   // TODO: Separate input and output types
-  configParser: (input) => {
-    for (const [counterName, counter] of Object.entries<any>((input as any).counters || {})) {
-      counter.name = counterName;
-      counter.per_user = counter.per_user ?? false;
-      counter.per_channel = counter.per_channel ?? false;
-      counter.initial_value = counter.initial_value ?? 0;
-      counter.triggers = counter.triggers || {};
-
-      if (Object.values(counter.triggers).length > MAX_TRIGGERS_PER_COUNTER) {
-        throw new StrictValidationError([`You can only have at most ${MAX_TRIGGERS_PER_COUNTER} triggers per counter`]);
-      }
-
-      // Normalize triggers
-      for (const [triggerName, trigger] of Object.entries(counter.triggers)) {
-        const triggerObj = (typeof trigger === "string" ? { condition: trigger } : trigger) as Partial<TTrigger>;
-
-        triggerObj.name = triggerName;
-        const parsedCondition = parseCounterConditionString(triggerObj.condition || "");
-        if (!parsedCondition) {
-          throw new StrictValidationError([
-            `Invalid comparison in counter trigger ${counterName}/${triggerName}: "${triggerObj.condition}"`,
-          ]);
-        }
-
-        triggerObj.condition = buildCounterConditionString(parsedCondition[0], parsedCondition[1]);
-        triggerObj.reverse_condition =
-          triggerObj.reverse_condition ||
-          buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]);
-
-        counter.triggers[triggerName] = triggerObj as TTrigger;
-      }
-    }
-
-    if (Object.values((input as any).counters || {}).length > MAX_COUNTERS) {
-      throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} counters`]);
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zCountersConfig.parse(input),
 
   public: {
     counterExists: mapToPublicFn(counterExists),
@@ -163,13 +120,12 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()({
       state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers);
 
       // Initialize triggers
-      for (const trigger of Object.values(counter.triggers)) {
-        const theTrigger = trigger as TTrigger;
-        const parsedCondition = parseCounterConditionString(theTrigger.condition)!;
-        const parsedReverseCondition = parseCounterConditionString(theTrigger.reverse_condition)!;
+      for (const trigger of values(counter.triggers)) {
+        const parsedCondition = parseCounterConditionString(trigger.condition)!;
+        const parsedReverseCondition = parseCounterConditionString(trigger.reverse_condition)!;
         const counterTrigger = await state.counters.initCounterTrigger(
           dbCounter.id,
-          theTrigger.name,
+          trigger.name,
           parsedCondition[0],
           parsedCondition[1],
           parsedReverseCondition[0],
diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
index 1445bdd8..5e891c18 100644
--- a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
+++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
@@ -1,5 +1,5 @@
 import { GuildPluginData } from "knub";
-import { CountersPluginType, TTrigger } from "../types";
+import { CountersPluginType } from "../types";
 
 export function getPrettyNameForCounterTrigger(
   pluginData: GuildPluginData<CountersPluginType>,
@@ -12,6 +12,6 @@ export function getPrettyNameForCounterTrigger(
     return "Unknown Counter Trigger";
   }
 
-  const trigger = counter.triggers[triggerName] as TTrigger | undefined;
+  const trigger = counter.triggers[triggerName];
   return trigger ? trigger.pretty_name || trigger.name : "Unknown Counter Trigger";
 }
diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts
index 4187c9e4..2b8cce16 100644
--- a/backend/src/plugins/Counters/types.ts
+++ b/backend/src/plugins/Counters/types.ts
@@ -1,45 +1,98 @@
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildCounters } from "../../data/GuildCounters";
-import { CounterTrigger } from "../../data/entities/CounterTrigger";
-import { tDelayString, tNullable } from "../../utils";
+import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString } from "../../data/entities/CounterTrigger";
+import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
 
-export const Trigger = t.type({
-  name: t.string,
-  pretty_name: tNullable(t.string),
-  condition: t.string,
-  reverse_condition: t.string,
-});
-export type TTrigger = t.TypeOf<typeof Trigger>;
+const MAX_COUNTERS = 5;
+const MAX_TRIGGERS_PER_COUNTER = 5;
 
-export const Counter = t.type({
-  name: t.string,
-  pretty_name: tNullable(t.string),
-  per_channel: t.boolean,
-  per_user: t.boolean,
-  initial_value: t.number,
-  triggers: t.record(t.string, t.union([t.string, Trigger])),
-  decay: tNullable(
-    t.type({
-      amount: t.number,
-      every: tDelayString,
-    }),
+export const zTrigger = z.strictObject({
+  // Dummy type because name gets replaced by the property key in zTriggerInput
+  name: z.never().optional().transform(() => ""),
+  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
+  condition: zBoundedCharacters(1, 64).refine(
+    (str) => parseCounterConditionString(str) !== null,
+    { message: "Invalid counter trigger condition" },
+  ),
+  reverse_condition: zBoundedCharacters(1, 64).refine(
+    (str) => parseCounterConditionString(str) !== null,
+    { message: "Invalid counter trigger reverse condition" },
   ),
-  can_view: tNullable(t.boolean),
-  can_edit: tNullable(t.boolean),
-  can_reset_all: tNullable(t.boolean),
 });
-export type TCounter = t.TypeOf<typeof Counter>;
 
-export const ConfigSchema = t.type({
-  counters: t.record(t.string, Counter),
-  can_view: t.boolean,
-  can_edit: t.boolean,
-  can_reset_all: t.boolean,
+const zTriggerInput = z.union([zBoundedCharacters(0, 100), zTrigger])
+  .transform((val, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (typeof val === "string") {
+      const parsedCondition = parseCounterConditionString(val);
+      if (!parsedCondition) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Invalid counter trigger condition",
+        });
+        return z.NEVER;
+      }
+      return {
+        name: ruleName,
+        pretty_name: null,
+        condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
+        reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]),
+      };
+    }
+    return {
+      ...val,
+      name: ruleName,
+    };
+  });
+
+export const zCounter = z.strictObject({
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Counters must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
+  per_channel: z.boolean().default(false),
+  per_user: z.boolean().default(false),
+  initial_value: z.number().default(0),
+  triggers: zBoundedRecord(
+    z.record(
+      zBoundedCharacters(0, 100),
+      zTriggerInput,
+    ),
+    1,
+    MAX_TRIGGERS_PER_COUNTER,
+  ),
+  decay: z.strictObject({
+    amount: z.number(),
+    every: zDelayString,
+  }).nullable().default(null),
+  can_view: z.boolean(),
+  can_edit: z.boolean(),
+  can_reset_all: z.boolean(),
+});
+
+export const zCountersConfig = z.strictObject({
+  counters: zBoundedRecord(
+    z.record(zBoundedCharacters(0, 100), zCounter),
+    0,
+    MAX_COUNTERS,
+  ),
+  can_view: z.boolean(),
+  can_edit: z.boolean(),
+  can_reset_all: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CounterEvents {
   trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void;
@@ -52,7 +105,7 @@ export interface CounterEventEmitter extends EventEmitter {
 }
 
 export interface CountersPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCountersConfig>;
   state: {
     counters: GuildCounters;
     counterIds: Record<string, number>;
diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
index 6d5ba2ba..e17cd96b 100644
--- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
+++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
@@ -2,7 +2,6 @@ import { GuildChannel, GuildMember, User } from "discord.js";
 import { guildPluginMessageCommand, parseSignature } from "knub";
 import { TSignature } from "knub-command-manager";
 import { commandTypes } from "../../commandTypes";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { TemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter";
 import { UnknownUser } from "../../utils";
 import { isScalar } from "../../utils/isScalar";
@@ -14,7 +13,7 @@ import {
 } from "../../utils/templateSafeObjects";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { runEvent } from "./functions/runEvent";
-import { ConfigSchema, CustomEventsPluginType } from "./types";
+import { CustomEventsPluginType, zCustomEventsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -26,7 +25,7 @@ export const CustomEventsPlugin = zeppelinGuildPlugin<CustomEventsPluginType>()(
   name: "custom_events",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCustomEventsConfig.parse(input),
   defaultOptions,
 
   afterLoad(pluginData) {
diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
index 29ded857..f0870958 100644
--- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
@@ -1,17 +1,17 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { canActOn } from "../../../pluginUtils";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
-import { resolveMember } from "../../../utils";
+import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const AddRoleAction = t.type({
-  type: t.literal("add_role"),
-  target: t.string,
-  role: t.union([t.string, t.array(t.string)]),
+export const zAddRoleAction = z.strictObject({
+  type: z.literal("add_role"),
+  target: zSnowflake,
+  role: z.union([zSnowflake, z.array(zSnowflake)]),
 });
-export type TAddRoleAction = t.TypeOf<typeof AddRoleAction>;
+export type TAddRoleAction = z.infer<typeof zAddRoleAction>;
 
 export async function addRoleAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
index d8d766b5..e894a446 100644
--- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
@@ -1,19 +1,20 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const CreateCaseAction = t.type({
-  type: t.literal("create_case"),
-  case_type: t.string,
-  mod: t.string,
-  target: t.string,
-  reason: t.string,
+export const zCreateCaseAction = z.strictObject({
+  type: z.literal("create_case"),
+  case_type: zBoundedCharacters(0, 32),
+  mod: zSnowflake,
+  target: zSnowflake,
+  reason: zBoundedCharacters(0, 4000),
 });
-export type TCreateCaseAction = t.TypeOf<typeof CreateCaseAction>;
+export type TCreateCaseAction = z.infer<typeof zCreateCaseAction>;
 
 export async function createCaseAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
@@ -32,7 +33,7 @@ export async function createCaseAction(
   }
 
   const casesPlugin = pluginData.getPlugin(CasesPlugin);
-  await casesPlugin.createCase({
+  await casesPlugin!.createCase({
     userId: targetId,
     modId,
     type: CaseTypes[action.case_type],
diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
index bf314429..7727e339 100644
--- a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
@@ -1,17 +1,17 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
-import { convertDelayStringToMS, noop, tDelayString } from "../../../utils";
+import { convertDelayStringToMS, noop, zDelayString, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MakeRoleMentionableAction = t.type({
-  type: t.literal("make_role_mentionable"),
-  role: t.string,
-  timeout: tDelayString,
+export const zMakeRoleMentionableAction = z.strictObject({
+  type: z.literal("make_role_mentionable"),
+  role: zSnowflake,
+  timeout: zDelayString,
 });
-export type TMakeRoleMentionableAction = t.TypeOf<typeof MakeRoleMentionableAction>;
+export type TMakeRoleMentionableAction = z.infer<typeof zMakeRoleMentionableAction>;
 
 export async function makeRoleMentionableAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
index e86d03b5..8dca7323 100644
--- a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
@@ -1,15 +1,16 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MakeRoleUnmentionableAction = t.type({
-  type: t.literal("make_role_unmentionable"),
-  role: t.string,
+export const zMakeRoleUnmentionableAction = z.strictObject({
+  type: z.literal("make_role_unmentionable"),
+  role: zSnowflake,
 });
-export type TMakeRoleUnmentionableAction = t.TypeOf<typeof MakeRoleUnmentionableAction>;
+export type TMakeRoleUnmentionableAction = z.infer<typeof zMakeRoleUnmentionableAction>;
 
 export async function makeRoleUnmentionableAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts
index f06534ad..40eee4b8 100644
--- a/backend/src/plugins/CustomEvents/actions/messageAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts
@@ -1,16 +1,17 @@
 import { Snowflake, TextChannel } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType } from "../types";
 
-export const MessageAction = t.type({
-  type: t.literal("message"),
-  channel: t.string,
-  content: t.string,
+export const zMessageAction = z.strictObject({
+  type: z.literal("message"),
+  channel: zSnowflake,
+  content: zBoundedCharacters(0, 4000),
 });
-export type TMessageAction = t.TypeOf<typeof MessageAction>;
+export type TMessageAction = z.infer<typeof zMessageAction>;
 
 export async function messageAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
index 8ef746cd..d42059f5 100644
--- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
@@ -1,18 +1,18 @@
 import { Snowflake, VoiceChannel } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { canActOn } from "../../../pluginUtils";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { resolveMember } from "../../../utils";
+import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MoveToVoiceChannelAction = t.type({
-  type: t.literal("move_to_vc"),
-  target: t.string,
-  channel: t.string,
+export const zMoveToVoiceChannelAction = z.strictObject({
+  type: z.literal("move_to_vc"),
+  target: zSnowflake,
+  channel: zSnowflake,
 });
-export type TMoveToVoiceChannelAction = t.TypeOf<typeof MoveToVoiceChannelAction>;
+export type TMoveToVoiceChannelAction = z.infer<typeof zMoveToVoiceChannelAction>;
 
 export async function moveToVoiceChannelAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
index 6bae4c35..dbd7b932 100644
--- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
+++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
@@ -1,23 +1,24 @@
 import { PermissionsBitField, PermissionsString, Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const SetChannelPermissionOverridesAction = t.type({
-  type: t.literal("set_channel_permission_overrides"),
-  channel: t.string,
-  overrides: t.array(
-    t.type({
-      type: t.union([t.literal("member"), t.literal("role")]),
-      id: t.string,
-      allow: t.number,
-      deny: t.number,
+export const zSetChannelPermissionOverridesAction = z.strictObject({
+  type: z.literal("set_channel_permission_overrides"),
+  channel: zSnowflake,
+  overrides: z.array(
+    z.strictObject({
+      type: z.union([z.literal("member"), z.literal("role")]),
+      id: zSnowflake,
+      allow: z.number(),
+      deny: z.number(),
     }),
-  ),
+  ).max(15),
 });
-export type TSetChannelPermissionOverridesAction = t.TypeOf<typeof SetChannelPermissionOverridesAction>;
+export type TSetChannelPermissionOverridesAction = z.infer<typeof zSetChannelPermissionOverridesAction>;
 
 export async function setChannelPermissionOverridesAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts
index e273e7b6..4572b1cf 100644
--- a/backend/src/plugins/CustomEvents/types.ts
+++ b/backend/src/plugins/CustomEvents/types.ts
@@ -1,47 +1,50 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
-import { AddRoleAction } from "./actions/addRoleAction";
-import { CreateCaseAction } from "./actions/createCaseAction";
-import { MakeRoleMentionableAction } from "./actions/makeRoleMentionableAction";
-import { MakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction";
-import { MessageAction } from "./actions/messageAction";
-import { MoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction";
-import { SetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides";
+import z from "zod";
+import { zBoundedCharacters, zBoundedRecord } from "../../utils";
+import { zAddRoleAction } from "./actions/addRoleAction";
+import { zCreateCaseAction } from "./actions/createCaseAction";
+import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction";
+import { zMakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction";
+import { zMessageAction } from "./actions/messageAction";
+import { zMoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction";
+import { zSetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides";
 
-// Triggers
-const CommandTrigger = t.type({
-  type: t.literal("command"),
-  name: t.string,
-  params: t.string,
-  can_use: t.boolean,
+const zCommandTrigger = z.strictObject({
+  type: z.literal("command"),
+  name: zBoundedCharacters(0, 100),
+  params: zBoundedCharacters(0, 255),
+  can_use: z.boolean(),
 });
 
-const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers
+const zAnyTrigger = zCommandTrigger; // TODO: Make into a union once we have more triggers
 
-const AnyAction = t.union([
-  AddRoleAction,
-  CreateCaseAction,
-  MoveToVoiceChannelAction,
-  MessageAction,
-  MakeRoleMentionableAction,
-  MakeRoleUnmentionableAction,
-  SetChannelPermissionOverridesAction,
+const zAnyAction = z.union([
+  zAddRoleAction,
+  zCreateCaseAction,
+  zMoveToVoiceChannelAction,
+  zMessageAction,
+  zMakeRoleMentionableAction,
+  zMakeRoleUnmentionableAction,
+  zSetChannelPermissionOverridesAction,
 ]);
 
-export const CustomEvent = t.type({
-  name: t.string,
-  trigger: AnyTrigger,
-  actions: t.array(AnyAction),
+export const zCustomEvent = z.strictObject({
+  name: zBoundedCharacters(0, 100),
+  trigger: zAnyTrigger,
+  actions: z.array(zAnyAction).max(10),
 });
-export type TCustomEvent = t.TypeOf<typeof CustomEvent>;
+export type TCustomEvent = z.infer<typeof zCustomEvent>;
 
-export const ConfigSchema = t.type({
-  events: t.record(t.string, CustomEvent),
+export const zCustomEventsConfig = z.strictObject({
+  events: zBoundedRecord(
+    z.record(zBoundedCharacters(0, 100), zCustomEvent),
+    0,
+    100,
+  ),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CustomEventsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCustomEventsConfig>;
   state: {
     clearTriggers: () => void;
   };
diff --git a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
index c4f7eaaf..b8639134 100644
--- a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
+++ b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
@@ -1,11 +1,10 @@
 import { Guild } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType, GlobalPluginData, globalPluginEventListener } from "knub";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { Configs } from "../../data/Configs";
 import { env } from "../../env";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
+import z from "zod";
 
 interface GuildAccessMonitorPluginType extends BasePluginType {
   state: {
@@ -26,7 +25,7 @@ async function checkGuild(pluginData: GlobalPluginData<GuildAccessMonitorPluginT
  */
 export const GuildAccessMonitorPlugin = zeppelinGlobalPlugin<GuildAccessMonitorPluginType>()({
   name: "guild_access_monitor",
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     globalPluginEventListener<GuildAccessMonitorPluginType>()({
diff --git a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
index 5ae04fb5..6fdc467a 100644
--- a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
+++ b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
@@ -1,6 +1,5 @@
-import * as t from "io-ts";
+import z from "zod";
 import { Configs } from "../../data/Configs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
 import { reloadChangedGuilds } from "./functions/reloadChangedGuilds";
 import { GuildConfigReloaderPluginType } from "./types";
@@ -9,7 +8,7 @@ export const GuildConfigReloaderPlugin = zeppelinGlobalPlugin<GuildConfigReloade
   name: "guild_config_reloader",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   async beforeLoad(pluginData) {
     const { state } = pluginData;
diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
index 687175f1..a8d71e06 100644
--- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
+++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
@@ -1,18 +1,17 @@
 import { Guild } from "discord.js";
-import * as t from "io-ts";
 import { guildPluginEventListener } from "knub";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { MINUTES } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { GuildInfoSaverPluginType } from "./types";
+import z from "zod";
 
 export const GuildInfoSaverPlugin = zeppelinGuildPlugin<GuildInfoSaverPluginType>()({
   name: "guild_info_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     guildPluginEventListener({
diff --git a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
index d29b342d..293d582e 100644
--- a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
+++ b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
@@ -1,6 +1,6 @@
-import * as t from "io-ts";
+import z from "zod";
 import { GuildMemberCache } from "../../data/GuildMemberCache";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { SECONDS } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin";
@@ -18,7 +18,7 @@ export const GuildMemberCachePlugin = zeppelinGuildPlugin<GuildMemberCachePlugin
   name: "guild_member_cache",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     updateMemberCacheOnMemberUpdate,
diff --git a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
index 3fbcbbf8..9365109e 100644
--- a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
+++ b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
@@ -1,11 +1,12 @@
 import { PluginOptions } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { Webhooks } from "../../data/Webhooks";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { editMessage } from "./functions/editMessage";
 import { sendMessage } from "./functions/sendMessage";
-import { ConfigSchema, InternalPosterPluginType } from "./types";
+import { InternalPosterPluginType } from "./types";
 
 const defaultOptions: PluginOptions<InternalPosterPluginType> = {
   config: {},
@@ -16,7 +17,7 @@ export const InternalPosterPlugin = zeppelinGuildPlugin<InternalPosterPluginType
   name: "internal_poster",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => z.strictObject({}).parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/InternalPoster/types.ts b/backend/src/plugins/InternalPoster/types.ts
index e5f1a1a3..78dabd42 100644
--- a/backend/src/plugins/InternalPoster/types.ts
+++ b/backend/src/plugins/InternalPoster/types.ts
@@ -1,15 +1,9 @@
 import { WebhookClient } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
 import { Queue } from "../../Queue";
 import { Webhooks } from "../../data/Webhooks";
 
-export const ConfigSchema = t.type({});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
 export interface InternalPosterPluginType extends BasePluginType {
-  config: TConfigSchema;
-
   state: {
     queue: Queue;
     webhooks: Webhooks;
diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
index 51d47945..a99dc84c 100644
--- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts
+++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
@@ -1,7 +1,6 @@
 import { PluginOptions } from "knub";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildVCAlerts } from "../../data/GuildVCAlerts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { FollowCmd } from "./commands/FollowCmd";
@@ -9,7 +8,7 @@ import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd";
 import { WhereCmd } from "./commands/WhereCmd";
 import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt";
 import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts";
-import { ConfigSchema, LocateUserPluginType } from "./types";
+import { LocateUserPluginType, zLocateUserConfig } from "./types";
 import { clearExpiredAlert } from "./utils/clearExpiredAlert";
 import { fillActiveAlertsList } from "./utils/fillAlertsList";
 
@@ -39,10 +38,10 @@ export const LocateUserPlugin = zeppelinGuildPlugin<LocateUserPluginType>()({
       * Instantly receive an invite to the voice channel of a user
       * Be notified as soon as a user switches or joins a voice channel
     `),
-    configSchema: ConfigSchema,
+    configSchema: zLocateUserConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zLocateUserConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts
index 1bfb063e..4139f465 100644
--- a/backend/src/plugins/LocateUser/types.ts
+++ b/backend/src/plugins/LocateUser/types.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildVCAlerts } from "../../data/GuildVCAlerts";
 
-export const ConfigSchema = t.type({
-  can_where: t.boolean,
-  can_alert: t.boolean,
+export const zLocateUserConfig = z.strictObject({
+  can_where: z.boolean(),
+  can_alert: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface LocateUserPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zLocateUserConfig>;
   state: {
     alerts: GuildVCAlerts;
     usersWithAlerts: string[];
diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts
index e56ba756..923ac623 100644
--- a/backend/src/plugins/Logs/LogsPlugin.ts
+++ b/backend/src/plugins/Logs/LogsPlugin.ts
@@ -6,7 +6,7 @@ import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { LogType } from "../../data/LogType";
 import { logger } from "../../logger";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { TypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
@@ -31,7 +31,7 @@ import {
 import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts";
 import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts";
 import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts";
-import { ConfigSchema, FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "./types";
+import { FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel, zLogsConfig } from "./types";
 import { getLogMessage } from "./util/getLogMessage";
 import { log } from "./util/log";
 import { onMessageDelete } from "./util/onMessageDelete";
@@ -110,7 +110,6 @@ import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMov
 import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin";
 import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave";
 import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove";
-import { asBoundedString } from "../../utils/iotsUtils";
 
 // The `any` cast here is to prevent TypeScript from locking up from the circular dependency
 function getCasesPlugin(): Promise<any> {
@@ -121,12 +120,12 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
   config: {
     channels: {},
     format: {
-      timestamp: asBoundedString(FORMAT_NO_TIMESTAMP), // Legacy/deprecated, use timestamp_format below instead
+      timestamp: FORMAT_NO_TIMESTAMP,
       ...DefaultLogMessages,
     },
-    ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
+    ping_user: true, 
     allow_user_mentions: false,
-    timestamp_format: asBoundedString("[<t:]X[>]"),
+    timestamp_format: "[<t:]X[>]",
     include_embed_timestamp: true,
   },
 
@@ -134,7 +133,8 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
     {
       level: ">=50",
       config: {
-        ping_user: false, // Legacy/deprecated, read comment on global ping_user option
+        // Legacy/deprecated, read comment on global ping_user option
+        ping_user: false,
       },
     },
   ],
@@ -145,11 +145,11 @@ export const LogsPlugin = zeppelinGuildPlugin<LogsPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Logs",
-    configSchema: ConfigSchema,
+    configSchema: zLogsConfig,
   },
 
   dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zLogsConfig.parse(input),
   defaultOptions,
 
   events: [
diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
index 0596869c..25c8c340 100644
--- a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
+++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
@@ -32,7 +32,7 @@ export function logMessageDelete(pluginData: GuildPluginData<LogsPluginType>, da
   // See comment on FORMAT_NO_TIMESTAMP in types.ts
   const config = pluginData.config.get();
   const timestampFormat =
-    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format;
+    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format ?? undefined;
 
   return log(
     pluginData,
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 759f680d..51f67b87 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
 import { z } from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
@@ -7,7 +6,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { LogType } from "../../data/LogType";
-import { tMessageContent, tNullable } from "../../utils";
+import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils";
 import { MessageBuffer } from "../../utils/MessageBuffer";
 import {
   TemplateSafeCase,
@@ -22,55 +21,56 @@ import {
   TemplateSafeUnknownUser,
   TemplateSafeUser,
 } from "../../utils/templateSafeObjects";
-import { TRegex } from "../../validatorUtils";
-import { tBoundedString } from "../../utils/iotsUtils";
 
-export const tLogFormats = t.record(t.string, t.union([t.string, tMessageContent]));
-export type TLogFormats = t.TypeOf<typeof tLogFormats>;
+const DEFAULT_BATCH_TIME = 1000;
+const MIN_BATCH_TIME = 250;
+const MAX_BATCH_TIME = 5000;
 
-const LogChannel = t.partial({
-  include: t.array(t.string),
-  exclude: t.array(t.string),
-  batched: t.boolean,
-  batch_time: t.number,
-  excluded_users: t.array(t.string),
-  excluded_message_regexes: t.array(TRegex),
-  excluded_channels: t.array(t.string),
-  excluded_categories: t.array(t.string),
-  excluded_threads: t.array(t.string),
-  exclude_bots: t.boolean,
-  excluded_roles: t.array(t.string),
-  format: tNullable(tLogFormats),
-  timestamp_format: t.string,
-  include_embed_timestamp: t.boolean,
+export const zLogFormats = z.record(
+  zBoundedCharacters(1, 255),
+  zMessageContent,
+);
+export type TLogFormats = z.infer<typeof zLogFormats>;
+
+const zLogChannel = z.strictObject({
+  include: z.array(zBoundedCharacters(1, 255)).default([]),
+  exclude: z.array(zBoundedCharacters(1, 255)).default([]),
+  batched: z.boolean().default(true),
+  batch_time: z.number().min(MIN_BATCH_TIME).max(MAX_BATCH_TIME).default(DEFAULT_BATCH_TIME),
+  excluded_users: z.array(zSnowflake).nullable().default(null),
+  excluded_message_regexes: z.array(zRegex(z.string())).nullable().default(null),
+  excluded_channels: z.array(zSnowflake).nullable().default(null),
+  excluded_categories: z.array(zSnowflake).nullable().default(null),
+  excluded_threads: z.array(zSnowflake).nullable().default(null),
+  exclude_bots: z.boolean().default(false),
+  excluded_roles: z.array(zSnowflake).nullable().default(null),
+  format: zLogFormats.default({}),
+  timestamp_format: z.string().nullable().default(null),
+  include_embed_timestamp: z.boolean().nullable().default(null),
 });
-export type TLogChannel = t.TypeOf<typeof LogChannel>;
+export type TLogChannel = z.infer<typeof zLogChannel>;
 
-const LogChannelMap = t.record(t.string, LogChannel);
-export type TLogChannelMap = t.TypeOf<typeof LogChannelMap>;
+const zLogChannelMap = z.record(zSnowflake, zLogChannel);
+export type TLogChannelMap = z.infer<typeof zLogChannelMap>;
 
-export const ConfigSchema = t.type({
-  channels: LogChannelMap,
-  format: t.intersection([
-    tLogFormats,
-    t.type({
-      timestamp: tBoundedString(0, 64), // Legacy/deprecated
-    }),
-  ]),
-  ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping
-  allow_user_mentions: t.boolean,
-  timestamp_format: tBoundedString(0, 64),
-  include_embed_timestamp: t.boolean,
+export const zLogsConfig = z.strictObject({
+  channels: zLogChannelMap,
+  format: z.intersection(zLogFormats, z.strictObject({
+    // Legacy/deprecated, use timestamp_format below instead
+    timestamp: zBoundedCharacters(0, 64).nullable(),
+  })),
+  // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
+  ping_user: z.boolean(),
+  allow_user_mentions: z.boolean(),
+  timestamp_format: z.string().nullable(),
+  include_embed_timestamp: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
-// Hacky way of allowing a """null""" default value for config.format.timestamp
-// The type cannot be made nullable properly because io-ts's intersection type still considers
-// that it has to match the record type of tLogFormats, which includes string.
+// Hacky way of allowing a """null""" default value for config.format.timestamp due to legacy io-ts reasons
 export const FORMAT_NO_TIMESTAMP = "__NO_TIMESTAMP__";
 
 export interface LogsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zLogsConfig>;
   state: {
     guildLogs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
index 9e775251..0b140cd7 100644
--- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
+++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
@@ -1,11 +1,10 @@
 import { PluginOptions } from "knub";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB";
 import { SavePinsToDBCmd } from "./commands/SavePinsToDB";
 import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt } from "./events/SaveMessagesEvts";
-import { ConfigSchema, MessageSaverPluginType } from "./types";
+import { MessageSaverPluginType, zMessageSaverConfig } from "./types";
 
 const defaultOptions: PluginOptions<MessageSaverPluginType> = {
   config: {
@@ -25,7 +24,7 @@ export const MessageSaverPlugin = zeppelinGuildPlugin<MessageSaverPluginType>()(
   name: "message_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zMessageSaverConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts
index 2fdef665..f42fa2c3 100644
--- a/backend/src/plugins/MessageSaver/types.ts
+++ b/backend/src/plugins/MessageSaver/types.ts
@@ -1,14 +1,13 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zMessageSaverConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface MessageSaverPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zMessageSaverConfig>;
   state: {
     savedMessages: GuildSavedMessages;
   };
diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts
index bf83b13e..4c9c8958 100644
--- a/backend/src/plugins/ModActions/ModActionsPlugin.ts
+++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts
@@ -6,7 +6,7 @@ import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildTempbans } from "../../data/GuildTempbans";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { MINUTES, trimPluginDescription } from "../../utils";
 import { CasesPlugin } from "../Cases/CasesPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -47,7 +47,7 @@ import { offModActionsEvent } from "./functions/offModActionsEvent";
 import { onModActionsEvent } from "./functions/onModActionsEvent";
 import { updateCase } from "./functions/updateCase";
 import { warnMember } from "./functions/warnMember";
-import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types";
+import { BanOptions, KickOptions, ModActionsPluginType, WarnOptions, zModActionsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -121,11 +121,11 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
     description: trimPluginDescription(`
       This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zModActionsConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zModActionsConfig.parse(input),
   defaultOptions,
 
   events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents],
diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts
index 447b9638..fbe7890a 100644
--- a/backend/src/plugins/ModActions/types.ts
+++ b/backend/src/plugins/ModActions/types.ts
@@ -1,51 +1,50 @@
 import { GuildTextBasedChannel } from "discord.js";
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildTempbans } from "../../data/GuildTempbans";
 import { Case } from "../../data/entities/Case";
-import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils";
+import { UserNotificationMethod, UserNotificationResult } from "../../utils";
 import { CaseArgs } from "../Cases/types";
 
-export const ConfigSchema = t.type({
-  dm_on_warn: t.boolean,
-  dm_on_kick: t.boolean,
-  dm_on_ban: t.boolean,
-  message_on_warn: t.boolean,
-  message_on_kick: t.boolean,
-  message_on_ban: t.boolean,
-  message_channel: tNullable(t.string),
-  warn_message: tNullable(t.string),
-  kick_message: tNullable(t.string),
-  ban_message: tNullable(t.string),
-  tempban_message: tNullable(t.string),
-  alert_on_rejoin: t.boolean,
-  alert_channel: tNullable(t.string),
-  warn_notify_enabled: t.boolean,
-  warn_notify_threshold: t.number,
-  warn_notify_message: t.string,
-  ban_delete_message_days: t.number,
-  can_note: t.boolean,
-  can_warn: t.boolean,
-  can_mute: t.boolean,
-  can_kick: t.boolean,
-  can_ban: t.boolean,
-  can_unban: t.boolean,
-  can_view: t.boolean,
-  can_addcase: t.boolean,
-  can_massunban: t.boolean,
-  can_massban: t.boolean,
-  can_massmute: t.boolean,
-  can_hidecase: t.boolean,
-  can_deletecase: t.boolean,
-  can_act_as_other: t.boolean,
-  create_cases_for_manual_actions: t.boolean,
+export const zModActionsConfig = z.strictObject({
+  dm_on_warn: z.boolean(),
+  dm_on_kick: z.boolean(),
+  dm_on_ban: z.boolean(),
+  message_on_warn: z.boolean(),
+  message_on_kick: z.boolean(),
+  message_on_ban: z.boolean(),
+  message_channel: z.nullable(z.string()),
+  warn_message: z.nullable(z.string()),
+  kick_message: z.nullable(z.string()),
+  ban_message: z.nullable(z.string()),
+  tempban_message: z.nullable(z.string()),
+  alert_on_rejoin: z.boolean(),
+  alert_channel: z.nullable(z.string()),
+  warn_notify_enabled: z.boolean(),
+  warn_notify_threshold: z.number(),
+  warn_notify_message: z.string(),
+  ban_delete_message_days: z.number(),
+  can_note: z.boolean(),
+  can_warn: z.boolean(),
+  can_mute: z.boolean(),
+  can_kick: z.boolean(),
+  can_ban: z.boolean(),
+  can_unban: z.boolean(),
+  can_view: z.boolean(),
+  can_addcase: z.boolean(),
+  can_massunban: z.boolean(),
+  can_massban: z.boolean(),
+  can_massmute: z.boolean(),
+  can_hidecase: z.boolean(),
+  can_deletecase: z.boolean(),
+  can_act_as_other: z.boolean(),
+  create_cases_for_manual_actions: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface ModActionsEvents {
   note: (userId: string, reason?: string) => void;
@@ -62,7 +61,7 @@ export interface ModActionsEventEmitter extends EventEmitter {
 }
 
 export interface ModActionsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zModActionsConfig>;
   state: {
     mutes: GuildMutes;
     cases: GuildCases;
diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts
index 1a205bbe..7c9eada8 100644
--- a/backend/src/plugins/Mutes/MutesPlugin.ts
+++ b/backend/src/plugins/Mutes/MutesPlugin.ts
@@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { CasesPlugin } from "../Cases/CasesPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -22,7 +22,7 @@ import { offMutesEvent } from "./functions/offMutesEvent";
 import { onMutesEvent } from "./functions/onMutesEvent";
 import { renewTimeoutMute } from "./functions/renewTimeoutMute";
 import { unmuteUser } from "./functions/unmuteUser";
-import { ConfigSchema, MutesPluginType } from "./types";
+import { MutesPluginType, zMutesConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -65,11 +65,11 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Mutes",
-    configSchema: ConfigSchema,
+    configSchema: zMutesConfig,
   },
 
   dependencies: () => [CasesPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zMutesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts
index 0c69c657..e1f266a9 100644
--- a/backend/src/plugins/Mutes/types.ts
+++ b/backend/src/plugins/Mutes/types.ts
@@ -1,36 +1,35 @@
 import { GuildMember } from "discord.js";
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { Case } from "../../data/entities/Case";
 import { Mute } from "../../data/entities/Mute";
-import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils";
+import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils";
 import { CaseArgs } from "../Cases/types";
 
-export const ConfigSchema = t.type({
-  mute_role: tNullable(t.string),
-  move_to_voice_channel: tNullable(t.string),
-  kick_from_voice_channel: t.boolean,
+export const zMutesConfig = z.strictObject({
+  mute_role: zSnowflake.nullable(),
+  move_to_voice_channel: zSnowflake.nullable(),
+  kick_from_voice_channel: z.boolean(),
 
-  dm_on_mute: t.boolean,
-  dm_on_update: t.boolean,
-  message_on_mute: t.boolean,
-  message_on_update: t.boolean,
-  message_channel: tNullable(t.string),
-  mute_message: tNullable(t.string),
-  timed_mute_message: tNullable(t.string),
-  update_mute_message: tNullable(t.string),
-  remove_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
-  restore_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
+  dm_on_mute: z.boolean(),
+  dm_on_update: z.boolean(),
+  message_on_mute: z.boolean(),
+  message_on_update: z.boolean(),
+  message_channel: z.string().nullable(),
+  mute_message: z.string().nullable(),
+  timed_mute_message: z.string().nullable(),
+  update_mute_message: z.string().nullable(),
+  remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
+  restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
 
-  can_view_list: t.boolean,
-  can_cleanup: t.boolean,
+  can_view_list: z.boolean(),
+  can_cleanup: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface MutesEvents {
   mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
@@ -43,7 +42,7 @@ export interface MutesEventEmitter extends EventEmitter {
 }
 
 export interface MutesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zMutesConfig>;
   state: {
     mutes: GuildMutes;
     cases: GuildCases;
diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
index 7b9273e3..915ffa3c 100644
--- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
+++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
@@ -2,10 +2,9 @@ import { PluginOptions } from "knub";
 import { Queue } from "../../Queue";
 import { GuildNicknameHistory } from "../../data/GuildNicknameHistory";
 import { UsernameHistory } from "../../data/UsernameHistory";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { NamesCmd } from "./commands/NamesCmd";
-import { ConfigSchema, NameHistoryPluginType } from "./types";
+import { NameHistoryPluginType, zNameHistoryConfig } from "./types";
 
 const defaultOptions: PluginOptions<NameHistoryPluginType> = {
   config: {
@@ -25,7 +24,7 @@ export const NameHistoryPlugin = zeppelinGuildPlugin<NameHistoryPluginType>()({
   name: "name_history",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zNameHistoryConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/NameHistory/types.ts b/backend/src/plugins/NameHistory/types.ts
index e85f2f40..70101b53 100644
--- a/backend/src/plugins/NameHistory/types.ts
+++ b/backend/src/plugins/NameHistory/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildNicknameHistory } from "../../data/GuildNicknameHistory";
 import { UsernameHistory } from "../../data/UsernameHistory";
 
-export const ConfigSchema = t.type({
-  can_view: t.boolean,
+export const zNameHistoryConfig = z.strictObject({
+  can_view: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface NameHistoryPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zNameHistoryConfig>;
   state: {
     nicknameHistory: GuildNicknameHistory;
     usernameHistory: UsernameHistory;
diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts
index ecd50067..dc489a25 100644
--- a/backend/src/plugins/Persist/PersistPlugin.ts
+++ b/backend/src/plugins/Persist/PersistPlugin.ts
@@ -1,7 +1,6 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildPersistedData } from "../../data/GuildPersistedData";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { GuildMemberCachePlugin } from "../GuildMemberCache/GuildMemberCachePlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -9,7 +8,7 @@ import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { LoadDataEvt } from "./events/LoadDataEvt";
 import { StoreDataEvt } from "./events/StoreDataEvt";
-import { ConfigSchema, PersistPluginType } from "./types";
+import { PersistPluginType, zPersistConfig } from "./types";
 
 const defaultOptions: PluginOptions<PersistPluginType> = {
   config: {
@@ -28,11 +27,11 @@ export const PersistPlugin = zeppelinGuildPlugin<PersistPluginType>()({
       Re-apply roles or nicknames for users when they rejoin the server.
       Mute roles are re-applied automatically, this plugin is not required for that.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zPersistConfig,
   },
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin, GuildMemberCachePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPersistConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Persist/types.ts b/backend/src/plugins/Persist/types.ts
index 8ab258b7..4f42faa9 100644
--- a/backend/src/plugins/Persist/types.ts
+++ b/backend/src/plugins/Persist/types.ts
@@ -1,17 +1,17 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildPersistedData } from "../../data/GuildPersistedData";
+import { zSnowflake } from "../../utils";
 
-export const ConfigSchema = t.type({
-  persisted_roles: t.array(t.string),
-  persist_nicknames: t.boolean,
-  persist_voice_mutes: t.boolean, // Deprecated, here to not break old configs
+export const zPersistConfig = z.strictObject({
+  persisted_roles: z.array(zSnowflake),
+  persist_nicknames: z.boolean(),
+  persist_voice_mutes: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PersistPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPersistConfig>;
 
   state: {
     persistedData: GuildPersistedData;
diff --git a/backend/src/plugins/Phisherman/PhishermanPlugin.ts b/backend/src/plugins/Phisherman/PhishermanPlugin.ts
index e8f74ce3..8e895d84 100644
--- a/backend/src/plugins/Phisherman/PhishermanPlugin.ts
+++ b/backend/src/plugins/Phisherman/PhishermanPlugin.ts
@@ -1,10 +1,10 @@
 import { PluginOptions } from "knub";
 import { hasPhishermanMasterAPIKey, phishermanApiKeyIsValid } from "../../data/Phisherman";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { getDomainInfo } from "./functions/getDomainInfo";
 import { pluginInfo } from "./info";
-import { ConfigSchema, PhishermanPluginType } from "./types";
+import { PhishermanPluginType, zPhishermanConfig } from "./types";
 
 const defaultOptions: PluginOptions<PhishermanPluginType> = {
   config: {
@@ -18,7 +18,7 @@ export const PhishermanPlugin = zeppelinGuildPlugin<PhishermanPluginType>()({
   showInDocs: true,
   info: pluginInfo,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPhishermanConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Phisherman/info.ts b/backend/src/plugins/Phisherman/info.ts
index 826da43d..24530dce 100644
--- a/backend/src/plugins/Phisherman/info.ts
+++ b/backend/src/plugins/Phisherman/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zPhishermanConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Phisherman",
@@ -39,5 +39,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
               clean: true
     ~~~
   `),
-  configSchema: ConfigSchema,
+  configSchema: zPhishermanConfig,
 };
diff --git a/backend/src/plugins/Phisherman/types.ts b/backend/src/plugins/Phisherman/types.ts
index 56ed7aca..d21eb38e 100644
--- a/backend/src/plugins/Phisherman/types.ts
+++ b/backend/src/plugins/Phisherman/types.ts
@@ -1,14 +1,12 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
-import { tNullable } from "../../utils";
+import z from "zod";
 
-export const ConfigSchema = t.type({
-  api_key: tNullable(t.string),
+export const zPhishermanConfig = z.strictObject({
+  api_key: z.string().max(255).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PhishermanPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPhishermanConfig>;
 
   state: {
     validApiKey: string | null;
diff --git a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
index 331410f9..a33103bf 100644
--- a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
+++ b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
@@ -1,10 +1,9 @@
 import { PluginOptions } from "knub";
 import { GuildPingableRoles } from "../../data/GuildPingableRoles";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd";
 import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd";
-import { ConfigSchema, PingableRolesPluginType } from "./types";
+import { PingableRolesPluginType, zPingableRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<PingableRolesPluginType> = {
   config: {
@@ -25,10 +24,10 @@ export const PingableRolesPlugin = zeppelinGuildPlugin<PingableRolesPluginType>(
   showInDocs: true,
   info: {
     prettyName: "Pingable roles",
-    configSchema: ConfigSchema,
+    configSchema: zPingableRolesConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPingableRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/PingableRoles/types.ts b/backend/src/plugins/PingableRoles/types.ts
index 272f7594..3bd6faa8 100644
--- a/backend/src/plugins/PingableRoles/types.ts
+++ b/backend/src/plugins/PingableRoles/types.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildPingableRoles } from "../../data/GuildPingableRoles";
 import { PingableRole } from "../../data/entities/PingableRole";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zPingableRolesConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PingableRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPingableRolesConfig>;
 
   state: {
     pingableRoles: GuildPingableRoles;
diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts
index 09d069a2..304d6b65 100644
--- a/backend/src/plugins/Post/PostPlugin.ts
+++ b/backend/src/plugins/Post/PostPlugin.ts
@@ -3,7 +3,6 @@ import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildScheduledPosts } from "../../data/GuildScheduledPosts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -14,7 +13,7 @@ import { PostEmbedCmd } from "./commands/PostEmbedCmd";
 import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd";
 import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd";
 import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd";
-import { ConfigSchema, PostPluginType } from "./types";
+import { PostPluginType, zPostConfig } from "./types";
 import { postScheduledPost } from "./util/postScheduledPost";
 
 const defaultOptions: PluginOptions<PostPluginType> = {
@@ -36,11 +35,11 @@ export const PostPlugin = zeppelinGuildPlugin<PostPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Post",
-    configSchema: ConfigSchema,
+    configSchema: zPostConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPostConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts
index 815b19b8..e0ec7d2c 100644
--- a/backend/src/plugins/Post/types.ts
+++ b/backend/src/plugins/Post/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildScheduledPosts } from "../../data/GuildScheduledPosts";
 
-export const ConfigSchema = t.type({
-  can_post: t.boolean,
+export const zPostConfig = z.strictObject({
+  can_post: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PostPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPostConfig>;
   state: {
     savedMessages: GuildSavedMessages;
     scheduledPosts: GuildScheduledPosts;
diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
index ac68de33..06fff977 100644
--- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
+++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
@@ -2,7 +2,6 @@ import { PluginOptions } from "knub";
 import { Queue } from "../../Queue";
 import { GuildReactionRoles } from "../../data/GuildReactionRoles";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
@@ -10,7 +9,7 @@ import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
 import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
 import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
 import { MessageDeletedEvt } from "./events/MessageDeletedEvt";
-import { ConfigSchema, ReactionRolesPluginType } from "./types";
+import { ReactionRolesPluginType, zReactionRolesConfig } from "./types";
 
 const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
 
@@ -40,11 +39,11 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>(
   info: {
     prettyName: "Reaction roles",
     legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.",
-    configSchema: ConfigSchema,
+    configSchema: zReactionRolesConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zReactionRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts
index 6f65ad98..a1c09f00 100644
--- a/backend/src/plugins/ReactionRoles/types.ts
+++ b/backend/src/plugins/ReactionRoles/types.ts
@@ -1,17 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildReactionRoles } from "../../data/GuildReactionRoles";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
 
-export const ConfigSchema = t.type({
-  auto_refresh_interval: t.number,
-  remove_user_reactions: t.boolean,
-  can_manage: t.boolean,
-  button_groups: tNullable(t.unknown),
+export const zReactionRolesConfig = z.strictObject({
+  auto_refresh_interval: z.number(),
+  remove_user_reactions: z.boolean(),
+  can_manage: z.boolean(),
+  button_groups: z.nullable(z.unknown()),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export type RoleChangeMode = "+" | "-";
 
@@ -24,12 +22,14 @@ export type PendingMemberRoleChanges = {
   }>;
 };
 
-const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]);
-export type TReactionRolePair = t.TypeOf<typeof ReactionRolePair>;
-type ReactionRolePair = [string, string, string?];
+const zReactionRolePair = z.union([
+  z.tuple([z.string(), z.string(), z.string()]),
+  z.tuple([z.string(), z.string()]),
+]);
+export type TReactionRolePair = z.infer<typeof zReactionRolePair>;
 
 export interface ReactionRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zReactionRolesConfig>;
   state: {
     reactionRoles: GuildReactionRoles;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts
index 128e945c..5e86ab49 100644
--- a/backend/src/plugins/Reminders/RemindersPlugin.ts
+++ b/backend/src/plugins/Reminders/RemindersPlugin.ts
@@ -1,14 +1,13 @@
 import { PluginOptions } from "knub";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildReminders } from "../../data/GuildReminders";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { RemindCmd } from "./commands/RemindCmd";
 import { RemindersCmd } from "./commands/RemindersCmd";
 import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd";
 import { postReminder } from "./functions/postReminder";
-import { ConfigSchema, RemindersPluginType } from "./types";
+import { RemindersPluginType, zRemindersConfig } from "./types";
 
 const defaultOptions: PluginOptions<RemindersPluginType> = {
   config: {
@@ -29,11 +28,11 @@ export const RemindersPlugin = zeppelinGuildPlugin<RemindersPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Reminders",
-    configSchema: ConfigSchema,
+    configSchema: zRemindersConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRemindersConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts
index 5bc896ba..4356fac0 100644
--- a/backend/src/plugins/Reminders/types.ts
+++ b/backend/src/plugins/Reminders/types.ts
@@ -1,14 +1,13 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildReminders } from "../../data/GuildReminders";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
+export const zRemindersConfig = z.strictObject({
+  can_use: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface RemindersPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRemindersConfig>;
 
   state: {
     reminders: GuildReminders;
diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
index 79cfb8b4..3f1e34ea 100644
--- a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
+++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
@@ -1,15 +1,12 @@
 import { GuildRoleButtons } from "../../data/GuildRoleButtons";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { resetButtonsCmd } from "./commands/resetButtons";
 import { onButtonInteraction } from "./events/buttonInteraction";
 import { applyAllRoleButtons } from "./functions/applyAllRoleButtons";
-import { createButtonComponents } from "./functions/createButtonComponents";
-import { TooManyComponentsError } from "./functions/TooManyComponentsError";
 import { pluginInfo } from "./info";
-import { ConfigSchema, RoleButtonsPluginType } from "./types";
+import { RoleButtonsPluginType, zRoleButtonsConfig } from "./types";
 
 export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
   name: "role_buttons",
@@ -31,41 +28,7 @@ export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
     ],
   },
 
-  configParser(input) {
-    // Auto-fill "name" property for buttons based on the object key
-    const seenMessages = new Set();
-    for (const [name, buttonsConfig] of Object.entries<any>((input as any).buttons ?? {})) {
-      if (name.length > 16) {
-        throw new StrictValidationError(["Name for role buttons can be at most 16 characters long"]);
-      }
-
-      if (buttonsConfig) {
-        buttonsConfig.name = name;
-
-        if (buttonsConfig.message) {
-          if ("message_id" in buttonsConfig.message) {
-            if (seenMessages.has(buttonsConfig.message.message_id)) {
-              throw new StrictValidationError(["Can't target the same message with two sets of role buttons"]);
-            }
-            seenMessages.add(buttonsConfig.message.message_id);
-          }
-        }
-
-        if (buttonsConfig.options) {
-          try {
-            createButtonComponents(buttonsConfig);
-          } catch (err) {
-            if (err instanceof TooManyComponentsError) {
-              throw new StrictValidationError(["Too many options; can only have max 5 buttons per row on max 5 rows."]);
-            }
-            throw new StrictValidationError(["Error validating options"]);
-          }
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zRoleButtonsConfig.parse(input),
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin],
 
diff --git a/backend/src/plugins/RoleButtons/info.ts b/backend/src/plugins/RoleButtons/info.ts
index d63fb17c..6076419c 100644
--- a/backend/src/plugins/RoleButtons/info.ts
+++ b/backend/src/plugins/RoleButtons/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zRoleButtonsConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Role buttons",
@@ -78,5 +78,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
               ... # See above for examples for options
     ~~~
   `),
-  configSchema: ConfigSchema,
+  configSchema: zRoleButtonsConfig,
 };
diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts
index f5792f36..3c94f8a8 100644
--- a/backend/src/plugins/RoleButtons/types.ts
+++ b/backend/src/plugins/RoleButtons/types.ts
@@ -1,58 +1,102 @@
 import { ButtonStyle } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildRoleButtons } from "../../data/GuildRoleButtons";
-import { tMessageContent, tNullable } from "../../utils";
+import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils";
+import { TooManyComponentsError } from "./functions/TooManyComponentsError";
+import { createButtonComponents } from "./functions/createButtonComponents";
 
-const RoleButtonOption = t.type({
-  role_id: t.string,
-  label: tNullable(t.string),
-  emoji: tNullable(t.string),
+const zRoleButtonOption = z.strictObject({
+  role_id: zSnowflake,
+  label: z.string().nullable().default(null),
+  emoji: z.string().nullable().default(null),
   // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle
-  style: tNullable(
-    t.union([
-      t.literal(ButtonStyle.Primary),
-      t.literal(ButtonStyle.Secondary),
-      t.literal(ButtonStyle.Success),
-      t.literal(ButtonStyle.Danger),
+  style: z.union([
+    z.literal(ButtonStyle.Primary),
+    z.literal(ButtonStyle.Secondary),
+    z.literal(ButtonStyle.Success),
+    z.literal(ButtonStyle.Danger),
 
-      // The following are deprecated
-      t.literal("PRIMARY"),
-      t.literal("SECONDARY"),
-      t.literal("SUCCESS"),
-      t.literal("DANGER"),
-      // t.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
-    ]),
-  ),
-  start_new_row: tNullable(t.boolean),
+    // The following are deprecated
+    z.literal("PRIMARY"),
+    z.literal("SECONDARY"),
+    z.literal("SUCCESS"),
+    z.literal("DANGER"),
+    // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
+  ]).nullable().default(null),
+  start_new_row: z.boolean().default(false),
 });
-export type TRoleButtonOption = t.TypeOf<typeof RoleButtonOption>;
+export type TRoleButtonOption = z.infer<typeof zRoleButtonOption>;
 
-const RoleButtonsConfigItem = t.type({
-  name: t.string,
-  message: t.union([
-    t.type({
-      channel_id: t.string,
-      message_id: t.string,
+const zRoleButtonsConfigItem = z.strictObject({
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Role buttons must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  message: z.union([
+    z.strictObject({
+      channel_id: zSnowflake,
+      message_id: zSnowflake,
     }),
-    t.type({
-      channel_id: t.string,
-      content: tMessageContent,
+    z.strictObject({
+      channel_id: zSnowflake,
+      content: zMessageContent,
     }),
   ]),
-  options: t.array(RoleButtonOption),
-  exclusive: tNullable(t.boolean),
-});
-export type TRoleButtonsConfigItem = t.TypeOf<typeof RoleButtonsConfigItem>;
+  options: z.array(zRoleButtonOption).max(25),
+  exclusive: z.boolean().default(false),
+})
+  .refine((parsed) => {
+    try {
+      createButtonComponents(parsed);
+    } catch (err) {
+      if (err instanceof TooManyComponentsError) {
+        return false;
+      }
+      throw err;
+    }
+    return true;
+  }, {
+    message: "Too many options; can only have max 5 buttons per row on max 5 rows."
+  });
+export type TRoleButtonsConfigItem = z.infer<typeof zRoleButtonsConfigItem>;
 
-export const ConfigSchema = t.type({
-  buttons: t.record(t.string, RoleButtonsConfigItem),
-  can_reset: t.boolean,
-});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+export const zRoleButtonsConfig = z.strictObject({
+  buttons: zBoundedRecord(
+    z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem),
+    0,
+    100,
+  ),
+  can_reset: z.boolean(),
+})
+  .refine((parsed) => {
+    const seenMessages = new Set();
+    for (const button of Object.values(parsed.buttons)) {
+      if (button.message) {
+        if ("message_id" in button.message) {
+          if (seenMessages.has(button.message.message_id)) {
+            return false;
+          }
+          seenMessages.add(button.message.message_id);
+        }
+      }
+    }
+    return true;
+  }, {
+    message: "Can't target the same message with two sets of role buttons",
+  });
 
 export interface RoleButtonsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRoleButtonsConfig>;
   state: {
     roleButtons: GuildRoleButtons;
   };
diff --git a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
index 878b2cd4..34427d98 100644
--- a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
+++ b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
@@ -1,5 +1,5 @@
 import { GuildRoleQueue } from "../../data/GuildRoleQueue";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { addPriorityRole } from "./functions/addPriorityRole";
@@ -7,14 +7,14 @@ import { addRole } from "./functions/addRole";
 import { removePriorityRole } from "./functions/removePriorityRole";
 import { removeRole } from "./functions/removeRole";
 import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop";
-import { ConfigSchema, RoleManagerPluginType } from "./types";
+import { RoleManagerPluginType, zRoleManagerConfig } from "./types";
 
 export const RoleManagerPlugin = zeppelinGuildPlugin<RoleManagerPluginType>()({
   name: "role_manager",
   showInDocs: false,
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRoleManagerConfig.parse(input),
 
   public: {
     addRole: mapToPublicFn(addRole),
diff --git a/backend/src/plugins/RoleManager/types.ts b/backend/src/plugins/RoleManager/types.ts
index 54f5033a..51ba3080 100644
--- a/backend/src/plugins/RoleManager/types.ts
+++ b/backend/src/plugins/RoleManager/types.ts
@@ -1,12 +1,11 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildRoleQueue } from "../../data/GuildRoleQueue";
 
-export const ConfigSchema = t.type({});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+export const zRoleManagerConfig = z.strictObject({});
 
 export interface RoleManagerPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRoleManagerConfig>;
   state: {
     roleQueue: GuildRoleQueue;
     roleAssignmentLoopRunning: boolean;
diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts
index 6c810940..0e181cfa 100644
--- a/backend/src/plugins/Roles/RolesPlugin.ts
+++ b/backend/src/plugins/Roles/RolesPlugin.ts
@@ -1,6 +1,5 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
@@ -9,13 +8,13 @@ import { AddRoleCmd } from "./commands/AddRoleCmd";
 import { MassAddRoleCmd } from "./commands/MassAddRoleCmd";
 import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd";
 import { RemoveRoleCmd } from "./commands/RemoveRoleCmd";
-import { ConfigSchema, RolesPluginType } from "./types";
+import { RolesPluginType, zRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<RolesPluginType> = {
   config: {
     can_assign: false,
     can_mass_assign: false,
-    assignable_roles: ["558037973581430785"],
+    assignable_roles: [],
   },
   overrides: [
     {
@@ -41,11 +40,11 @@ export const RolesPlugin = zeppelinGuildPlugin<RolesPluginType>()({
     description: trimPluginDescription(`
       Enables authorised users to add and remove whitelisted roles with a command.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zRolesConfig,
   },
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts
index 346dc562..caf55b76 100644
--- a/backend/src/plugins/Roles/types.ts
+++ b/backend/src/plugins/Roles/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 
-export const ConfigSchema = t.type({
-  can_assign: t.boolean,
-  can_mass_assign: t.boolean,
-  assignable_roles: t.array(t.string),
+export const zRolesConfig = z.strictObject({
+  can_assign: z.boolean(),
+  can_mass_assign: z.boolean(),
+  assignable_roles: z.array(z.string()).max(100),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface RolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRolesConfig>;
   state: {
     logs: GuildLogs;
   };
diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
index f16cff92..6f0d666b 100644
--- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
+++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
@@ -1,11 +1,10 @@
 import { CooldownManager, PluginOptions } from "knub";
 import { trimPluginDescription } from "../../utils";
-import { parseIoTsSchema } from "../../validatorUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { RoleAddCmd } from "./commands/RoleAddCmd";
 import { RoleHelpCmd } from "./commands/RoleHelpCmd";
 import { RoleRemoveCmd } from "./commands/RoleRemoveCmd";
-import { ConfigSchema, SelfGrantableRolesPluginType, defaultSelfGrantableRoleEntry } from "./types";
+import { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<SelfGrantableRolesPluginType> = {
   config: {
@@ -66,25 +65,10 @@ export const SelfGrantableRolesPlugin = zeppelinGuildPlugin<SelfGrantableRolesPl
                   can_use: true
       ~~~
     `),
-    configSchema: ConfigSchema,
+    configSchema: zSelfGrantableRolesConfig,
   },
 
-  configParser: (input) => {
-    const entries = (input as any).entries;
-    for (const [key, entry] of Object.entries<any>(entries)) {
-      // Apply default entry config
-      entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry };
-
-      // Normalize alias names
-      if (entry.roles) {
-        for (const [roleId, aliases] of Object.entries<string[]>(entry.roles)) {
-          entry.roles[roleId] = aliases.map((a) => a.toLowerCase());
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zSelfGrantableRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts
index 08d665df..a1aed241 100644
--- a/backend/src/plugins/SelfGrantableRoles/types.ts
+++ b/backend/src/plugins/SelfGrantableRoles/types.ts
@@ -1,31 +1,29 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginMessageCommand } from "knub";
+import z from "zod";
+import { zBoundedCharacters, zBoundedRecord } from "../../utils";
 
-const RoleMap = t.record(t.string, t.array(t.string));
+const zRoleMap = z.record(
+  zBoundedCharacters(1, 100),
+  z.array(zBoundedCharacters(1, 2000))
+    .max(100)
+    .transform((parsed) => parsed.map(v => v.toLowerCase())),
+);
 
-const SelfGrantableRoleEntry = t.type({
-  roles: RoleMap,
-  can_use: t.boolean,
-  can_ignore_cooldown: t.boolean,
-  max_roles: t.number,
+const zSelfGrantableRoleEntry = z.strictObject({
+  roles: zBoundedRecord(zRoleMap, 0, 100),
+  can_use: z.boolean().default(false),
+  can_ignore_cooldown: z.boolean().default(false),
+  max_roles: z.number().default(0),
 });
-const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props);
-export type TSelfGrantableRoleEntry = t.TypeOf<typeof SelfGrantableRoleEntry>;
+export type TSelfGrantableRoleEntry = z.infer<typeof zSelfGrantableRoleEntry>;
 
-export const ConfigSchema = t.type({
-  entries: t.record(t.string, SelfGrantableRoleEntry),
-  mention_roles: t.boolean,
+export const zSelfGrantableRolesConfig = z.strictObject({
+  entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100),
+  mention_roles: z.boolean(),
 });
-type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export const defaultSelfGrantableRoleEntry: t.TypeOf<typeof PartialRoleEntry> = {
-  can_use: false,
-  can_ignore_cooldown: false,
-  max_roles: 0,
-};
 
 export interface SelfGrantableRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSelfGrantableRolesConfig>;
   state: {
     cooldowns: CooldownManager;
   };
diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
index 4f6c0996..174f38a9 100644
--- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts
+++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
@@ -2,7 +2,6 @@ import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildSlowmodes } from "../../data/GuildSlowmodes";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { SECONDS } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -11,7 +10,7 @@ import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd";
 import { SlowmodeGetCmd } from "./commands/SlowmodeGetCmd";
 import { SlowmodeListCmd } from "./commands/SlowmodeListCmd";
 import { SlowmodeSetCmd } from "./commands/SlowmodeSetCmd";
-import { ConfigSchema, SlowmodePluginType } from "./types";
+import { SlowmodePluginType, zSlowmodeConfig } from "./types";
 import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes";
 import { onMessageCreate } from "./util/onMessageCreate";
 
@@ -41,7 +40,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin<SlowmodePluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Slowmode",
-    configSchema: ConfigSchema,
+    configSchema: zSlowmodeConfig,
   },
 
   // prettier-ignore
@@ -49,7 +48,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin<SlowmodePluginType>()({
     LogsPlugin,
   ],
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zSlowmodeConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts
index 089a59ef..c4fe44d5 100644
--- a/backend/src/plugins/Slowmode/types.ts
+++ b/backend/src/plugins/Slowmode/types.ts
@@ -1,20 +1,19 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildSlowmodes } from "../../data/GuildSlowmodes";
 import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel";
 
-export const ConfigSchema = t.type({
-  use_native_slowmode: t.boolean,
-
-  can_manage: t.boolean,
-  is_affected: t.boolean,
+export const zSlowmodeConfig = z.strictObject({
+  use_native_slowmode: z.boolean(),
+  
+  can_manage: z.boolean(),
+  is_affected: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface SlowmodePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSlowmodeConfig>;
   state: {
     slowmodes: GuildSlowmodes;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Spam/SpamPlugin.ts b/backend/src/plugins/Spam/SpamPlugin.ts
index e1a7efc0..54ec540c 100644
--- a/backend/src/plugins/Spam/SpamPlugin.ts
+++ b/backend/src/plugins/Spam/SpamPlugin.ts
@@ -3,12 +3,11 @@ import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SpamVoiceStateUpdateEvt } from "./events/SpamVoiceEvt";
-import { ConfigSchema, SpamPluginType } from "./types";
+import { SpamPluginType, zSpamConfig } from "./types";
 import { clearOldRecentActions } from "./util/clearOldRecentActions";
 import { onMessageCreate } from "./util/onMessageCreate";
 
@@ -53,11 +52,11 @@ export const SpamPlugin = zeppelinGuildPlugin<SpamPluginType>()({
       For more advanced spam filtering, check out the Automod plugin!
     `),
     legacy: true,
-    configSchema: ConfigSchema,
+    configSchema: zSpamConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zSpamConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts
index 1e561477..74ea872f 100644
--- a/backend/src/plugins/Spam/types.ts
+++ b/backend/src/plugins/Spam/types.ts
@@ -1,35 +1,40 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
+import { zSnowflake } from "../../utils";
 
-const BaseSingleSpamConfig = t.type({
-  interval: t.number,
-  count: t.number,
-  mute: tNullable(t.boolean),
-  mute_time: tNullable(t.number),
-  remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-  restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-  clean: tNullable(t.boolean),
+const zBaseSingleSpamConfig = z.strictObject({
+  interval: z.number(),
+  count: z.number(),
+  mute: z.boolean().default(false),
+  mute_time: z.number().nullable().default(null),
+  remove_roles_on_mute: z.union([
+    z.boolean(),
+    z.array(zSnowflake),
+  ]).default(false),
+  restore_roles_on_mute: z.union([
+    z.boolean(),
+    z.array(zSnowflake),
+  ]).default(false),
+  clean: z.boolean().default(false),
 });
-export type TBaseSingleSpamConfig = t.TypeOf<typeof BaseSingleSpamConfig>;
+export type TBaseSingleSpamConfig = z.infer<typeof zBaseSingleSpamConfig>;
 
-export const ConfigSchema = t.type({
-  max_censor: tNullable(BaseSingleSpamConfig),
-  max_messages: tNullable(BaseSingleSpamConfig),
-  max_mentions: tNullable(BaseSingleSpamConfig),
-  max_links: tNullable(BaseSingleSpamConfig),
-  max_attachments: tNullable(BaseSingleSpamConfig),
-  max_emojis: tNullable(BaseSingleSpamConfig),
-  max_newlines: tNullable(BaseSingleSpamConfig),
-  max_duplicates: tNullable(BaseSingleSpamConfig),
-  max_characters: tNullable(BaseSingleSpamConfig),
-  max_voice_moves: tNullable(BaseSingleSpamConfig),
+export const zSpamConfig = z.strictObject({
+  max_censor: zBaseSingleSpamConfig.nullable(),
+  max_messages: zBaseSingleSpamConfig.nullable(),
+  max_mentions: zBaseSingleSpamConfig.nullable(),
+  max_links: zBaseSingleSpamConfig.nullable(),
+  max_attachments: zBaseSingleSpamConfig.nullable(),
+  max_emojis: zBaseSingleSpamConfig.nullable(),
+  max_newlines: zBaseSingleSpamConfig.nullable(),
+  max_duplicates: zBaseSingleSpamConfig.nullable(),
+  max_characters: zBaseSingleSpamConfig.nullable(),
+  max_voice_moves: zBaseSingleSpamConfig.nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export enum RecentActionType {
   Message = 1,
@@ -53,7 +58,7 @@ interface IRecentAction<T> {
 }
 
 export interface SpamPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSpamConfig>;
   state: {
     logs: GuildLogs;
     archives: GuildArchives;
diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts
index 050b9037..4ca3c441 100644
--- a/backend/src/plugins/Starboard/StarboardPlugin.ts
+++ b/backend/src/plugins/Starboard/StarboardPlugin.ts
@@ -3,12 +3,11 @@ import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildStarboardMessages } from "../../data/GuildStarboardMessages";
 import { GuildStarboardReactions } from "../../data/GuildStarboardReactions";
 import { trimPluginDescription } from "../../utils";
-import { parseIoTsSchema } from "../../validatorUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { MigratePinsCmd } from "./commands/MigratePinsCmd";
 import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt";
 import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts";
-import { ConfigSchema, StarboardPluginType, defaultStarboardOpts } from "./types";
+import { StarboardPluginType, zStarboardConfig } from "./types";
 import { onMessageDelete } from "./util/onMessageDelete";
 
 const defaultOptions: PluginOptions<StarboardPluginType> = {
@@ -120,19 +119,10 @@ export const StarboardPlugin = zeppelinGuildPlugin<StarboardPluginType>()({
                   enabled: true
       ~~~
     `),
-    configSchema: ConfigSchema,
+    configSchema: zStarboardConfig,
   },
 
-  configParser(input) {
-    const boards = (input as any).boards;
-    if (boards) {
-      for (const [name, opts] of Object.entries(boards)) {
-        boards[name] = Object.assign({}, defaultStarboardOpts, opts);
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zStarboardConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts
index 223501e3..7dc2d8f5 100644
--- a/backend/src/plugins/Starboard/types.ts
+++ b/backend/src/plugins/Starboard/types.ts
@@ -1,39 +1,33 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildStarboardMessages } from "../../data/GuildStarboardMessages";
 import { GuildStarboardReactions } from "../../data/GuildStarboardReactions";
-import { tDeepPartial, tNullable } from "../../utils";
+import { zBoundedRecord, zSnowflake } from "../../utils";
 
-const StarboardOpts = t.type({
-  channel_id: t.string,
-  stars_required: t.number,
-  star_emoji: tNullable(t.array(t.string)),
-  allow_selfstars: tNullable(t.boolean),
-  copy_full_embed: tNullable(t.boolean),
-  enabled: tNullable(t.boolean),
-  show_star_count: t.boolean,
-  color: tNullable(t.number),
+const zStarboardOpts = z.strictObject({
+  channel_id: zSnowflake,
+  stars_required: z.number(),
+  star_emoji: z.array(z.string()).default(["⭐"]),
+  allow_selfstars: z.boolean().default(false),
+  copy_full_embed: z.boolean().default(false),
+  enabled: z.boolean().default(true),
+  show_star_count: z.boolean().default(true),
+  color: z.number().nullable().default(null),
 });
-export type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
+export type TStarboardOpts = z.infer<typeof zStarboardOpts>;
 
-export const ConfigSchema = t.type({
-  boards: t.record(t.string, StarboardOpts),
-  can_migrate: t.boolean,
+export const zStarboardConfig = z.strictObject({
+  boards: zBoundedRecord(
+    z.record(z.string(), zStarboardOpts),
+    0,
+    100,
+  ),
+  can_migrate: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export const PartialConfigSchema = tDeepPartial(ConfigSchema);
-
-export const defaultStarboardOpts: Partial<TStarboardOpts> = {
-  star_emoji: ["⭐"],
-  enabled: true,
-  show_star_count: true,
-  color: null,
-};
 
 export interface StarboardPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zStarboardConfig>;
 
   state: {
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
deleted file mode 100644
index 57258759..00000000
--- a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as t from "io-ts";
-import { defaultStarboardOpts, PartialConfigSchema } from "../types";
-
-export function preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
-  if (config.boards) {
-    for (const [name, opts] of Object.entries(config.boards)) {
-      config.boards[name] = Object.assign({}, defaultStarboardOpts, opts);
-    }
-  }
-
-  return config;
-}
diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts
index 3e0a5bb4..c68f90bf 100644
--- a/backend/src/plugins/Tags/TagsPlugin.ts
+++ b/backend/src/plugins/Tags/TagsPlugin.ts
@@ -2,7 +2,6 @@ import { Snowflake } from "discord.js";
 import humanizeDuration from "humanize-duration";
 import { PluginOptions } from "knub";
 import moment from "moment-timezone";
-import { parseIoTsSchema, StrictValidationError } from "src/validatorUtils";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
@@ -19,7 +18,7 @@ import { TagListCmd } from "./commands/TagListCmd";
 import { TagSourceCmd } from "./commands/TagSourceCmd";
 import { generateTemplateMarkdown } from "./docs";
 import { TemplateFunctions } from "./templateFunctions";
-import { ConfigSchema, TagsPluginType } from "./types";
+import { TagsPluginType, zTagsConfig } from "./types";
 import { findTagByName } from "./util/findTagByName";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageDelete } from "./util/onMessageDelete";
@@ -71,7 +70,7 @@ export const TagsPlugin = zeppelinGuildPlugin<TagsPluginType>()({
 
       ${generateTemplateMarkdown(TemplateFunctions)}
     `),
-    configSchema: ConfigSchema,
+    configSchema: zTagsConfig,
   },
 
   dependencies: () => [LogsPlugin],
@@ -96,28 +95,7 @@ export const TagsPlugin = zeppelinGuildPlugin<TagsPluginType>()({
     findTagByName: mapToPublicFn(findTagByName),
   },
 
-  configParser(_input) {
-    const input = _input as any;
-
-    if (input.delete_with_command && input.auto_delete_command) {
-      throw new StrictValidationError([
-        `Cannot have both (global) delete_with_command and global_delete_invoke enabled`,
-      ]);
-    }
-
-    // Check each category for conflicting options
-    if (input.categories) {
-      for (const [name, cat] of Object.entries(input.categories)) {
-        if ((cat as any).delete_with_command && (cat as any).auto_delete_command) {
-          throw new StrictValidationError([
-            `Cannot have both (category specific) delete_with_command and category_delete_invoke enabled at <categories/${name}>`,
-          ]);
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zTagsConfig.parse(input),
 
   beforeLoad(pluginData) {
     const { state, guild } = pluginData;
diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts
index 3a7eb2b3..441906fe 100644
--- a/backend/src/plugins/Tags/types.ts
+++ b/backend/src/plugins/Tags/types.ts
@@ -1,52 +1,63 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildTags } from "../../data/GuildTags";
-import { tEmbed, tNullable } from "../../utils";
+import { zEmbedInput } from "../../utils";
 
-export const Tag = t.union([t.string, tEmbed]);
-export type TTag = t.TypeOf<typeof Tag>;
+export const zTag = z.union([z.string(), zEmbedInput]);
+export type TTag = z.infer<typeof zTag>;
 
-export const TagCategory = t.type({
-  prefix: tNullable(t.string),
-  delete_with_command: tNullable(t.boolean),
+export const zTagCategory = z.strictObject({
+  prefix: z.string().nullable().default(null),
+  delete_with_command: z.boolean().default(false),
 
-  user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
-  user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category
-  global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
-  allow_mentions: tNullable(t.boolean), // Per user, per category
-  global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category
-  auto_delete_command: tNullable(t.boolean), // Any tag, per tag category
+  user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag
+  user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category
+  global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag
+  allow_mentions: z.boolean().nullable().default(null),
+  global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category
+  auto_delete_command: z.boolean().nullable().default(null),
 
-  tags: t.record(t.string, Tag),
+  tags: z.record(z.string(), zTag),
 
-  can_use: tNullable(t.boolean),
-});
-export type TTagCategory = t.TypeOf<typeof TagCategory>;
+  can_use: z.boolean().nullable().default(null),
+})
+  .refine(
+    (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
+    {
+      message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
+    },
+  );
+export type TTagCategory = z.infer<typeof zTagCategory>;
 
-export const ConfigSchema = t.type({
-  prefix: t.string,
-  delete_with_command: t.boolean,
+export const zTagsConfig = z.strictObject({
+  prefix: z.string(),
+  delete_with_command: z.boolean(),
 
-  user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
-  global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
-  user_cooldown: tNullable(t.union([t.string, t.number])), // Per user
-  allow_mentions: t.boolean, // Per user
-  global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use
-  auto_delete_command: t.boolean, // Any tag
+  user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag
+  global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag
+  user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user
+  allow_mentions: z.boolean(), // Per user
+  global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use
+  auto_delete_command: z.boolean(), // Any tag
 
-  categories: t.record(t.string, TagCategory),
+  categories: z.record(z.string(), zTagCategory),
 
-  can_create: t.boolean,
-  can_use: t.boolean,
-  can_list: t.boolean,
-});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+  can_create: z.boolean(),
+  can_use: z.boolean(),
+  can_list: z.boolean(),
+})
+.refine(
+  (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
+  {
+    message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
+  },
+);
 
 export interface TagsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zTagsConfig>;
   state: {
     archives: GuildArchives;
     tags: GuildTags;
diff --git a/backend/src/plugins/Tags/util/findTagByName.ts b/backend/src/plugins/Tags/util/findTagByName.ts
index 1b877f33..98fafc3f 100644
--- a/backend/src/plugins/Tags/util/findTagByName.ts
+++ b/backend/src/plugins/Tags/util/findTagByName.ts
@@ -1,12 +1,11 @@
-import * as t from "io-ts";
 import { ExtendedMatchParams, GuildPluginData } from "knub";
-import { Tag, TagsPluginType } from "../types";
+import { TTag, TagsPluginType } from "../types";
 
 export async function findTagByName(
   pluginData: GuildPluginData<TagsPluginType>,
   name: string,
   matchParams: ExtendedMatchParams = {},
-): Promise<t.TypeOf<typeof Tag> | null> {
+): Promise<TTag | null> {
   const config = await pluginData.config.getMatchingConfig(matchParams);
 
   // Tag from a hardcoded category
diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts
index 12c6cf8d..f5b0b04a 100644
--- a/backend/src/plugins/Tags/util/onMessageCreate.ts
+++ b/backend/src/plugins/Tags/util/onMessageCreate.ts
@@ -2,9 +2,8 @@ import { Snowflake, TextChannel } from "discord.js";
 import { GuildPluginData } from "knub";
 import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
-import { convertDelayStringToMS, resolveMember, tStrictMessageContent } from "../../../utils";
+import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
-import { validate } from "../../../validatorUtils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TagsPluginType } from "../types";
 import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString";
@@ -85,10 +84,10 @@ export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType
     pluginData.cooldowns.setCooldown(cd[0], cd[1]);
   }
 
-  const validationError = await validate(tStrictMessageContent, tagResult.renderedContent);
-  if (validationError) {
+  const validated = zStrictMessageContent.safeParse(tagResult.renderedContent);
+  if (! validated.success) {
     pluginData.getPlugin(LogsPlugin).logBotAlert({
-      body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validationError.message}`,
+      body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validated.error.message}`,
     });
     return;
   }
diff --git a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
index f4d5ded0..885604a2 100644
--- a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
+++ b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
@@ -1,6 +1,6 @@
 import { PluginOptions } from "knub";
 import { GuildMemberTimezones } from "../../data/GuildMemberTimezones";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ResetTimezoneCmd } from "./commands/ResetTimezoneCmd";
@@ -12,7 +12,7 @@ import { getGuildTz } from "./functions/getGuildTz";
 import { getMemberTz } from "./functions/getMemberTz";
 import { inGuildTz } from "./functions/inGuildTz";
 import { inMemberTz } from "./functions/inMemberTz";
-import { ConfigSchema, TimeAndDatePluginType } from "./types";
+import { TimeAndDatePluginType, zTimeAndDateConfig } from "./types";
 
 const defaultOptions: PluginOptions<TimeAndDatePluginType> = {
   config: {
@@ -39,10 +39,10 @@ export const TimeAndDatePlugin = zeppelinGuildPlugin<TimeAndDatePluginType>()({
     description: trimPluginDescription(`
       Allows controlling the displayed time/date formats and timezones
     `),
-    configSchema: ConfigSchema,
+    configSchema: zTimeAndDateConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zTimeAndDateConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/TimeAndDate/types.ts b/backend/src/plugins/TimeAndDate/types.ts
index 102d620b..7a942518 100644
--- a/backend/src/plugins/TimeAndDate/types.ts
+++ b/backend/src/plugins/TimeAndDate/types.ts
@@ -1,19 +1,21 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { GuildMemberTimezones } from "../../data/GuildMemberTimezones";
-import { tNullable, tPartialDictionary } from "../../utils";
-import { tValidTimezone } from "../../utils/tValidTimezone";
+import { keys } from "../../utils";
+import { zValidTimezone } from "../../utils/zValidTimezone";
 import { defaultDateFormats } from "./defaultDateFormats";
 
-export const ConfigSchema = t.type({
-  timezone: tValidTimezone,
-  date_formats: tNullable(tPartialDictionary(t.keyof(defaultDateFormats), t.string)),
-  can_set_timezone: t.boolean,
+const zDateFormatKeys = z.enum(keys(defaultDateFormats) as U.ListOf<keyof typeof defaultDateFormats>);
+
+export const zTimeAndDateConfig = z.strictObject({
+  timezone: zValidTimezone(z.string()),
+  date_formats: z.record(zDateFormatKeys, z.string()).nullable(),
+  can_set_timezone: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface TimeAndDatePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zTimeAndDateConfig>;
   state: {
     memberTimezones: GuildMemberTimezones;
   };
diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
index 3bdb310c..e64d3c98 100644
--- a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
+++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
@@ -1,16 +1,14 @@
-import * as t from "io-ts";
 import { Queue } from "../../Queue";
 import { UsernameHistory } from "../../data/UsernameHistory";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt } from "./events/UpdateUsernameEvts";
-import { UsernameSaverPluginType } from "./types";
+import { UsernameSaverPluginType, zUsernameSaverConfig } from "./types";
 
 export const UsernameSaverPlugin = zeppelinGuildPlugin<UsernameSaverPluginType>()({
   name: "username_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => zUsernameSaverConfig.parse(input),
 
   // prettier-ignore
   events: [
diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts
index d3c5518f..16d6e342 100644
--- a/backend/src/plugins/UsernameSaver/types.ts
+++ b/backend/src/plugins/UsernameSaver/types.ts
@@ -1,8 +1,12 @@
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { UsernameHistory } from "../../data/UsernameHistory";
 
+export const zUsernameSaverConfig = z.strictObject({});
+
 export interface UsernameSaverPluginType extends BasePluginType {
+  config: z.infer<typeof zUsernameSaverConfig>;
   state: {
     usernameHistory: UsernameHistory;
     updateQueue: Queue;
diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts
index 68f6987a..efb892ec 100644
--- a/backend/src/plugins/Utility/UtilityPlugin.ts
+++ b/backend/src/plugins/Utility/UtilityPlugin.ts
@@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { Supporters } from "../../data/Supporters";
-import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils";
+import { sendSuccessMessage } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { ModActionsPlugin } from "../ModActions/ModActionsPlugin";
@@ -42,7 +42,7 @@ import { getUserInfoEmbed } from "./functions/getUserInfoEmbed";
 import { hasPermission } from "./functions/hasPermission";
 import { activeReloads } from "./guildReloads";
 import { refreshMembersIfNeeded } from "./refreshMembers";
-import { ConfigSchema, UtilityPluginType } from "./types";
+import { UtilityPluginType, zUtilityConfig } from "./types";
 
 const defaultOptions: PluginOptions<UtilityPluginType> = {
   config: {
@@ -117,11 +117,11 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Utility",
-    configSchema: ConfigSchema,
+    configSchema: zUtilityConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zUtilityConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index 19710b58..dbaacf27 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -14,10 +14,9 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { RegExpRunner, allowTimeout } from "../../RegExpRunner";
 import { getBaseUrl, sendErrorMessage } from "../../pluginUtils";
-import { MINUTES, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
+import { InvalidRegexError, MINUTES, inputPatternToRegExp, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
 import { asyncFilter } from "../../utils/async";
 import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
-import { InvalidRegexError, inputPatternToRegExp } from "../../validatorUtils";
 import { banSearchSignature } from "./commands/BanSearchCmd";
 import { searchCmdSignature } from "./commands/SearchCmd";
 import { getUserInfoEmbed } from "./functions/getUserInfoEmbed";
diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts
index aaac036a..77b3d8ac 100644
--- a/backend/src/plugins/Utility/types.ts
+++ b/backend/src/plugins/Utility/types.ts
@@ -1,5 +1,5 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
@@ -7,39 +7,38 @@ import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { Supporters } from "../../data/Supporters";
 
-export const ConfigSchema = t.type({
-  can_roles: t.boolean,
-  can_level: t.boolean,
-  can_search: t.boolean,
-  can_clean: t.boolean,
-  can_info: t.boolean,
-  can_server: t.boolean,
-  can_inviteinfo: t.boolean,
-  can_channelinfo: t.boolean,
-  can_messageinfo: t.boolean,
-  can_userinfo: t.boolean,
-  can_roleinfo: t.boolean,
-  can_emojiinfo: t.boolean,
-  can_snowflake: t.boolean,
-  can_reload_guild: t.boolean,
-  can_nickname: t.boolean,
-  can_ping: t.boolean,
-  can_source: t.boolean,
-  can_vcmove: t.boolean,
-  can_vckick: t.boolean,
-  can_help: t.boolean,
-  can_about: t.boolean,
-  can_context: t.boolean,
-  can_jumbo: t.boolean,
-  jumbo_size: t.Integer,
-  can_avatar: t.boolean,
-  info_on_single_result: t.boolean,
-  autojoin_threads: t.boolean,
+export const zUtilityConfig = z.strictObject({
+  can_roles: z.boolean(),
+  can_level: z.boolean(),
+  can_search: z.boolean(),
+  can_clean: z.boolean(),
+  can_info: z.boolean(),
+  can_server: z.boolean(),
+  can_inviteinfo: z.boolean(),
+  can_channelinfo: z.boolean(),
+  can_messageinfo: z.boolean(),
+  can_userinfo: z.boolean(),
+  can_roleinfo: z.boolean(),
+  can_emojiinfo: z.boolean(),
+  can_snowflake: z.boolean(),
+  can_reload_guild: z.boolean(),
+  can_nickname: z.boolean(),
+  can_ping: z.boolean(),
+  can_source: z.boolean(),
+  can_vcmove: z.boolean(),
+  can_vckick: z.boolean(),
+  can_help: z.boolean(),
+  can_about: z.boolean(),
+  can_context: z.boolean(),
+  can_jumbo: z.boolean(),
+  jumbo_size: z.number(),
+  can_avatar: z.boolean(),
+  info_on_single_result: z.boolean(),
+  autojoin_threads: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface UtilityPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zUtilityConfig>;
   state: {
     logs: GuildLogs;
     cases: GuildCases;
diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
index e007b968..ba8f9de3 100644
--- a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
+++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
@@ -1,10 +1,9 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt";
-import { ConfigSchema, WelcomeMessagePluginType } from "./types";
+import { WelcomeMessagePluginType, zWelcomeMessageConfig } from "./types";
 
 const defaultOptions: PluginOptions<WelcomeMessagePluginType> = {
   config: {
@@ -19,11 +18,11 @@ export const WelcomeMessagePlugin = zeppelinGuildPlugin<WelcomeMessagePluginType
   showInDocs: true,
   info: {
     prettyName: "Welcome message",
-    configSchema: ConfigSchema,
+    configSchema: zWelcomeMessageConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zWelcomeMessageConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts
index 682cfe39..25e9afb1 100644
--- a/backend/src/plugins/WelcomeMessage/types.ts
+++ b/backend/src/plugins/WelcomeMessage/types.ts
@@ -1,17 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tNullable } from "../../utils";
 
-export const ConfigSchema = t.type({
-  send_dm: t.boolean,
-  send_to_channel: tNullable(t.string),
-  message: tNullable(t.string),
+export const zWelcomeMessageConfig = z.strictObject({
+  send_dm: z.boolean(),
+  send_to_channel: z.string().nullable(),
+  message: z.string().nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface WelcomeMessagePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zWelcomeMessageConfig>;
   state: {
     logs: GuildLogs;
     sentWelcomeMessages: Set<string>;
diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts
index 165c67b5..aa266abc 100644
--- a/backend/src/plugins/ZeppelinPluginBlueprint.ts
+++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import {
   BasePluginType,
   globalPlugin,
@@ -9,6 +8,7 @@ import {
   GuildPluginData,
 } from "knub";
 import { TMarkdown } from "../types";
+import { ZodTypeAny } from "zod";
 
 /**
  * GUILD PLUGINS
@@ -23,7 +23,7 @@ export interface ZeppelinGuildPluginBlueprint<TPluginData extends GuildPluginDat
     usageGuide?: TMarkdown;
     configurationGuide?: TMarkdown;
     legacy?: boolean | string;
-    configSchema?: t.Type<any>;
+    configSchema?: ZodTypeAny;
   };
 }
 
diff --git a/backend/src/types.ts b/backend/src/types.ts
index 45974aee..409c4dc6 100644
--- a/backend/src/types.ts
+++ b/backend/src/types.ts
@@ -1,5 +1,6 @@
-import * as t from "io-ts";
 import { BaseConfig, Knub } from "knub";
+import z from "zod";
+import { zSnowflake } from "./utils";
 
 export interface ZeppelinGuildConfig extends BaseConfig {
   success_emoji?: string;
@@ -10,31 +11,19 @@ export interface ZeppelinGuildConfig extends BaseConfig {
   date_formats?: any;
 }
 
-export const ZeppelinGuildConfigSchema = t.type({
+export const zZeppelinGuildConfig = z.strictObject({
   // From BaseConfig
-  prefix: t.string,
-  levels: t.record(t.string, t.number),
-  plugins: t.record(t.string, t.unknown),
+  prefix: z.string().optional(),
+  levels: z.record(zSnowflake, z.number()).optional(),
+  plugins: z.record(z.string(), z.unknown()).optional(),
 
   // From ZeppelinGuildConfig
-  success_emoji: t.string,
-  error_emoji: t.string,
+  success_emoji: z.string().optional(),
+  error_emoji: z.string().optional(),
 
   // Deprecated
-  timezone: t.string,
-  date_formats: t.unknown,
-});
-export const PartialZeppelinGuildConfigSchema = t.partial(ZeppelinGuildConfigSchema.props);
-
-export interface ZeppelinGlobalConfig extends BaseConfig {
-  url: string;
-  owners?: string[];
-}
-
-export const ZeppelinGlobalConfigSchema = t.type({
-  url: t.string,
-  owners: t.array(t.string),
-  plugins: t.record(t.string, t.unknown),
+  timezone: z.string().optional(),
+  date_formats: z.unknown().optional(),
 });
 
 export type TZeppelinKnub = Knub;
diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts
index f5c9f935..461be47b 100644
--- a/backend/src/utils.test.ts
+++ b/backend/src/utils.test.ts
@@ -1,6 +1,6 @@
 import test from "ava";
-import * as ioTs from "io-ts";
-import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, tAllowedMentions } from "./utils";
+import z from "zod";
+import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from "./utils";
 import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentionOptions";
 
 type AssertEquals<TActual, TExpected> = TActual extends TExpected ? true : false;
@@ -50,7 +50,7 @@ test("delay strings: reverse conversion (conservative)", (t) => {
 });
 
 test("tAllowedMentions matches Eris's AllowedMentions", (t) => {
-  type TAllowedMentions = ioTs.TypeOf<typeof tAllowedMentions>;
+  type TAllowedMentions = z.infer<typeof zAllowedMentions>;
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const typeTest: AssertEquals<TAllowedMentions, ErisAllowedMentionFormat> = true;
   t.pass();
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 1bb4d6ce..641a2573 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -21,31 +21,26 @@ import {
   PartialChannelData,
   PartialMessage,
   RoleResolvable,
-  Snowflake,
   Sticker,
   TextBasedChannel,
   User,
 } from "discord.js";
 import emojiRegex from "emoji-regex";
-import { either } from "fp-ts/lib/Either";
-import { unsafeCoerce } from "fp-ts/lib/function";
 import fs from "fs";
 import https from "https";
 import humanizeDuration from "humanize-duration";
-import * as t from "io-ts";
 import { isEqual } from "lodash";
-import moment from "moment-timezone";
 import { performance } from "perf_hooks";
 import tlds from "tlds";
 import tmp from "tmp";
 import { URL } from "url";
-import { z, ZodError } from "zod";
+import { z, ZodEffects, ZodError, ZodRecord, ZodString } from "zod";
 import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage";
 import { getProfiler } from "./profiler";
 import { SimpleCache } from "./SimpleCache";
 import { sendDM } from "./utils/sendDM";
+import { Brand } from "./utils/typeUtils";
 import { waitForButtonConfirm } from "./utils/waitForInteraction";
-import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
 
 const fsp = fs.promises;
 
@@ -91,71 +86,9 @@ export function isDiscordAPIError(err: Error | string): err is DiscordAPIError {
   return err instanceof DiscordAPIError;
 }
 
-export function tNullable<T extends t.Type<any, any>>(type: T) {
-  return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`);
-}
-
-export const tNormalizedNullOrUndefined = new t.Type<undefined, null | undefined>(
-  "tNormalizedNullOrUndefined",
-  (v): v is undefined => typeof v === "undefined",
-  (v, c) => (v == null ? t.success(undefined) : t.failure(v, c, "Value must be null or undefined")),
-  () => undefined,
-);
-
-/**
- * Similar to `tNullable`, but normalizes both `null` and `undefined` to `undefined`.
- * This allows adding optional config options that can be "removed" by setting the value to `null`.
- */
-export function tNormalizedNullOptional<T extends t.Type<any, any>>(type: T) {
-  return t.union(
-    [type, tNormalizedNullOrUndefined],
-    `Optional<${type.name}>`, // Simplified name for errors and config schema views
-  );
-}
-
-export type TDeepPartial<T> = T extends t.InterfaceType<any>
-  ? TDeepPartialProps<T["props"]>
-  : T extends t.DictionaryType<any, any>
-  ? t.DictionaryType<T["domain"], TDeepPartial<T["codomain"]>>
-  : T extends t.UnionType<any[]>
-  ? t.UnionType<Array<TDeepPartial<T["types"][number]>>>
-  : T extends t.IntersectionType<any>
-  ? t.IntersectionType<Array<TDeepPartial<T["types"][number]>>>
-  : T extends t.ArrayType<any>
-  ? t.ArrayType<TDeepPartial<T["type"]>>
-  : T;
-
-// Based on t.PartialC
-export interface TDeepPartialProps<P extends t.Props>
-  extends t.PartialType<
-    P,
-    {
-      [K in keyof P]?: TDeepPartial<t.TypeOf<P[K]>>;
-    },
-    {
-      [K in keyof P]?: TDeepPartial<t.OutputOf<P[K]>>;
-    }
-  > {}
-
-export function tDeepPartial<T>(type: T): TDeepPartial<T> {
-  if (type instanceof t.InterfaceType || type instanceof t.PartialType) {
-    const newProps = {};
-    for (const [key, prop] of Object.entries(type.props)) {
-      newProps[key] = tDeepPartial(prop);
-    }
-    return t.partial(newProps) as TDeepPartial<T>;
-  } else if (type instanceof t.DictionaryType) {
-    return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial<T>;
-  } else if (type instanceof t.UnionType) {
-    return t.union(type.types.map((unionType) => tDeepPartial(unionType))) as TDeepPartial<T>;
-  } else if (type instanceof t.IntersectionType) {
-    const types = type.types.map((intersectionType) => tDeepPartial(intersectionType));
-    return t.intersection(types as [t.Mixed, t.Mixed]) as unknown as TDeepPartial<T>;
-  } else if (type instanceof t.ArrayType) {
-    return t.array(tDeepPartial(type.type)) as TDeepPartial<T>;
-  } else {
-    return type as TDeepPartial<T>;
-  }
+// null | undefined -> undefined
+export function zNullishToUndefined<T extends z.ZodTypeAny>(type: T): ZodEffects<T, NonNullable<z.output<T>> | undefined> {
+  return type.transform(v => v ?? undefined);
 }
 
 export function getScalarDifference<T extends object>(
@@ -207,29 +140,6 @@ export function differenceToString(diff: Map<string, { was: any; is: any }>): st
 // https://stackoverflow.com/a/49262929/316944
 export type Not<T, E> = T & Exclude<T, E>;
 
-// io-ts partial dictionary type
-// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345
-export interface PartialDictionaryC<D extends t.Mixed, C extends t.Mixed>
-  extends t.DictionaryType<
-    D,
-    C,
-    {
-      [K in t.TypeOf<D>]?: t.TypeOf<C>;
-    },
-    {
-      [K in t.OutputOf<D>]?: t.OutputOf<C>;
-    },
-    unknown
-  > {}
-
-export const tPartialDictionary = <D extends t.Mixed, C extends t.Mixed>(
-  domain: D,
-  codomain: C,
-  name?: string,
-): PartialDictionaryC<D, C> => {
-  return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name));
-};
-
 export function nonNullish<V>(v: V): v is NonNullable<V> {
   return v != null;
 }
@@ -240,70 +150,60 @@ export type GroupDMInvite = Invite & {
   type: typeof ChannelType.GroupDM;
 };
 
-/**
- * Mirrors EmbedOptions from Eris
- */
-export const tEmbed = t.type({
-  title: tNullable(t.string),
-  description: tNullable(t.string),
-  url: tNullable(t.string),
-  timestamp: tNullable(t.string),
-  color: tNullable(t.number),
-  footer: tNullable(
-    t.type({
-      text: t.string,
-      icon_url: tNullable(t.string),
-      proxy_icon_url: tNullable(t.string),
-    }),
-  ),
-  image: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      proxy_url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  thumbnail: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      proxy_url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  video: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  provider: tNullable(
-    t.type({
-      name: t.string,
-      url: tNullable(t.string),
-    }),
-  ),
-  fields: tNullable(
-    t.array(
-      t.type({
-        name: tNullable(t.string),
-        value: tNullable(t.string),
-        inline: tNullable(t.boolean),
-      }),
-    ),
-  ),
-  author: tNullable(
-    t.type({
-      name: t.string,
-      url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
+function isBoundedString(str: unknown, min: number, max: number): str is string {
+  if (typeof str !== "string") {
+    return false;
+  }
+  return (str.length >= min && str.length <= max);
+}
+
+export function zBoundedCharacters(min: number, max: number) {
+  return z.string().refine(str => {
+    const len = [...str].length; // Unicode aware character split
+    return (len >= min && len <= max);
+  }, {
+    message: `String must be between ${min} and ${max} characters long`,
+  });
+}
+
+export const zSnowflake = z.string().refine(str => isSnowflake(str), {
+  message: "Invalid snowflake ID",
 });
 
+const regexWithFlags = /^\/(.*?)\/([i]*)$/;
+
+export class InvalidRegexError extends Error {}
+
+/**
+ * This function supports two input syntaxes for regexes: /<pattern>/<flags> and just <pattern>
+ */
+export function inputPatternToRegExp(pattern: string) {
+  const advancedSyntaxMatch = pattern.match(regexWithFlags);
+  const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""];
+  try {
+    return new RegExp(finalPattern, flags);
+  } catch (e) {
+    throw new InvalidRegexError(e.message);
+  }
+}
+
+export function zRegex<T extends ZodString>(zStr: T) {
+  return zStr.transform((str, ctx) => {
+    try {
+      return inputPatternToRegExp(str);
+    } catch (err) {
+      if (err instanceof InvalidRegexError) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Invalid regex"
+        });
+        return z.NEVER;
+      }
+      throw err;
+    }
+  });
+}
+
 export const zEmbedInput = z.object({
   title: z.string().optional(),
   description: z.string().optional(),
@@ -387,15 +287,7 @@ export type StrictMessageContent = {
   embeds?: APIEmbed[];
 };
 
-export const tStrictMessageContent = t.type({
-  content: tNullable(t.string),
-  tts: tNullable(t.boolean),
-  disableEveryone: tNullable(t.boolean),
-  embed: tNullable(tEmbed),
-  embeds: tNullable(t.array(tEmbed)),
-});
-
-export const tMessageContent = t.union([t.string, tStrictMessageContent]);
+export const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]);
 
 export function validateAndParseMessageContent(input: unknown): StrictMessageContent {
   if (input == null) {
@@ -454,11 +346,11 @@ function dropNullValuesRecursively(obj: any) {
 /**
  * Mirrors AllowedMentions from Eris
  */
-export const tAllowedMentions = t.type({
-  everyone: tNormalizedNullOptional(t.boolean),
-  users: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])),
-  roles: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])),
-  repliedUser: tNormalizedNullOptional(t.boolean),
+export const zAllowedMentions = z.strictObject({
+  everyone: zNullishToUndefined(z.boolean().nullable().optional()),
+  users: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
+  roles: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
+  replied_user: zNullishToUndefined(z.boolean().nullable().optional()),
 });
 
 export function dropPropertiesByName(obj, propName) {
@@ -472,39 +364,18 @@ export function dropPropertiesByName(obj, propName) {
   }
 }
 
-export const tAlphanumeric = new t.Type<string, string>(
-  "tAlphanumeric",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s);
-    }),
-  (s) => s,
-);
+export function zBoundedRecord<TRecord extends ZodRecord<any, any>>(record: TRecord, minKeys: number, maxKeys: number): ZodEffects<TRecord> {
+  return record.refine(data => {
+    const len = Object.keys(data).length;
+    return (len >= minKeys && len <= maxKeys);
+  }, {
+    message: `Object must have ${minKeys}-${maxKeys} keys`,
+  });
+}
 
-export const tDateTime = new t.Type<string, string>(
-  "tDateTime",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      const parsed =
-        s.length === 10 ? moment.utc(s, "YYYY-MM-DD") : s.length === 19 ? moment.utc(s, "YYYY-MM-DD HH:mm:ss") : null;
-
-      return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime");
-    }),
-  (s) => s,
-);
-
-export const tDelayString = new t.Type<string, string>(
-  "tDelayString",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      const ms = convertDelayStringToMS(s);
-      return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s);
-    }),
-  (s) => s,
-);
+export const zDelayString = z.string().max(32).refine(str => convertDelayStringToMS(str) !== null, {
+  message: "Invalid delay string",
+});
 
 // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that.
 // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps
@@ -609,9 +480,11 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) {
 
 export const snowflakeRegex = /[1-9][0-9]{5,19}/;
 
+export type Snowflake = Brand<string, "Snowflake">;
+
 const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`);
-export function isSnowflake(v: string): boolean {
-  return isSnowflakeRegex.test(v);
+export function isSnowflake(v: unknown): v is Snowflake {
+  return typeof v === "string" && isSnowflakeRegex.test(v);
 }
 
 export function sleep(ms: number): Promise<void> {
@@ -1474,8 +1347,7 @@ export function messageLink(guildIdOrMessage: string | Message | null, channelId
 }
 
 export function isValidEmbed(embed: any): boolean {
-  const result = decodeAndValidateStrict(tEmbed, embed);
-  return !(result instanceof StrictValidationError);
+  return zEmbedInput.safeParse(embed).success;
 }
 
 const formatter = new Intl.NumberFormat("en-US");
@@ -1613,3 +1485,19 @@ export function renderUsername(username: string, discriminator: string): string
 export function renderUserUsername(user: User | UnknownUser): string {
   return renderUsername(user.username, user.discriminator);
 }
+
+type Entries<T> = Array<{
+  [Key in keyof T]-?: [Key, T[Key]];
+}[keyof T]>;
+
+export function entries<T extends object>(object: T) {
+  return Object.entries(object) as Entries<T>;
+}
+
+export function keys<T extends object>(object: T) {
+  return Object.keys(object) as Array<keyof T>;
+}
+
+export function values<T extends object>(object: T) {
+  return Object.values(object) as Array<T[keyof T]>;
+}
diff --git a/backend/src/utils/iotsUtils.ts b/backend/src/utils/iotsUtils.ts
deleted file mode 100644
index a3636c84..00000000
--- a/backend/src/utils/iotsUtils.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as t from "io-ts";
-
-interface BoundedStringBrand {
-  readonly BoundedString: unique symbol;
-}
-
-export function asBoundedString(str: string) {
-  return str as t.Branded<string, BoundedStringBrand>;
-}
-
-export function tBoundedString(min: number, max: number) {
-  return t.brand(
-    t.string,
-    (str): str is t.Branded<string, BoundedStringBrand> => (str.length >= min && str.length <= max),
-    "BoundedString",
-  );
-}
diff --git a/backend/src/utils/tColor.ts b/backend/src/utils/tColor.ts
deleted file mode 100644
index f240f7f4..00000000
--- a/backend/src/utils/tColor.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { either } from "fp-ts/lib/Either";
-import * as t from "io-ts";
-import { intToRgb } from "./intToRgb";
-import { parseColor } from "./parseColor";
-import { rgbToInt } from "./rgbToInt";
-
-export const tColor = new t.Type<number, string>(
-  "tColor",
-  (s): s is number => typeof s === "number",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (input) => {
-      const parsedColor = parseColor(input);
-      return parsedColor == null ? t.failure(from, to, "Invalid color") : t.success(rgbToInt(parsedColor));
-    }),
-  (s) => intToRgb(s).join(","),
-);
diff --git a/backend/src/utils/tValidTimezone.ts b/backend/src/utils/tValidTimezone.ts
deleted file mode 100644
index 35fc97c5..00000000
--- a/backend/src/utils/tValidTimezone.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { either } from "fp-ts/lib/Either";
-import * as t from "io-ts";
-import { isValidTimezone } from "./isValidTimezone";
-
-export const tValidTimezone = new t.Type<string, string>(
-  "tValidTimezone",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (input) => {
-      return isValidTimezone(input) ? t.success(input) : t.failure(from, to, `Invalid timezone: ${input}`);
-    }),
-  (s) => s,
-);
diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts
index 7c8b4cbd..98301581 100644
--- a/backend/src/utils/typeUtils.ts
+++ b/backend/src/utils/typeUtils.ts
@@ -14,3 +14,11 @@ export type Awaitable<T = unknown> = T | Promise<T>;
 export type DeepMutable<T> = {
   -readonly [P in keyof T]: DeepMutable<T[P]>;
 };
+
+// From https://stackoverflow.com/a/70262876/316944
+export declare abstract class As<Tag extends keyof never> {
+  private static readonly $as$: unique symbol;
+  private [As.$as$]: Record<Tag, true>;
+}
+
+export type Brand<T, B extends keyof never> = T & As<B>;
diff --git a/backend/src/utils/zColor.ts b/backend/src/utils/zColor.ts
new file mode 100644
index 00000000..1bc3ae92
--- /dev/null
+++ b/backend/src/utils/zColor.ts
@@ -0,0 +1,15 @@
+import z from "zod";
+import { parseColor } from "./parseColor";
+import { rgbToInt } from "./rgbToInt";
+
+export const zColor = z.string().transform((val, ctx) => {
+  const parsedColor = parseColor(val);
+  if (parsedColor == null) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Invalid color",
+    });
+    return z.NEVER;
+  }
+  return rgbToInt(parsedColor);
+});
diff --git a/backend/src/utils/zValidTimezone.ts b/backend/src/utils/zValidTimezone.ts
new file mode 100644
index 00000000..b83c47c1
--- /dev/null
+++ b/backend/src/utils/zValidTimezone.ts
@@ -0,0 +1,8 @@
+import { ZodString } from "zod";
+import { isValidTimezone } from "./isValidTimezone";
+
+export function zValidTimezone<Z extends ZodString>(z: Z) {
+  return z.refine((val) => isValidTimezone(val), {
+    message: "Invalid timezone",
+  });
+}
diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts
deleted file mode 100644
index d41d6c4a..00000000
--- a/backend/src/validation.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import test from "ava";
-import * as t from "io-ts";
-import { tDeepPartial } from "./utils";
-import * as validatorUtils from "./validatorUtils";
-
-test("tDeepPartial works", (ava) => {
-  const originalSchema = t.type({
-    listOfThings: t.record(
-      t.string,
-      t.type({
-        enabled: t.boolean,
-        someValue: t.number,
-      }),
-    ),
-  });
-
-  const deepPartialSchema = tDeepPartial(originalSchema);
-
-  const partialValidValue = {
-    listOfThings: {
-      myThing: {
-        someValue: 5,
-      },
-    },
-  };
-
-  const partialErrorValue = {
-    listOfThings: {
-      myThing: {
-        someValue: "test",
-      },
-    },
-  };
-
-  const result1 = validatorUtils.validate(deepPartialSchema, partialValidValue);
-  ava.is(result1, null);
-
-  const result2 = validatorUtils.validate(deepPartialSchema, partialErrorValue);
-  ava.not(result2, null);
-});
diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts
deleted file mode 100644
index 31139596..00000000
--- a/backend/src/validatorUtils.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import deepDiff from "deep-diff";
-import { either, fold, isLeft } from "fp-ts/lib/Either";
-import { pipe } from "fp-ts/lib/pipeable";
-import * as t from "io-ts";
-import { noop } from "./utils";
-
-const regexWithFlags = /^\/(.*?)\/([i]*)$/;
-
-export class InvalidRegexError extends Error {}
-
-/**
- * This function supports two input syntaxes for regexes: /<pattern>/<flags> and just <pattern>
- */
-export function inputPatternToRegExp(pattern: string) {
-  const advancedSyntaxMatch = pattern.match(regexWithFlags);
-  const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""];
-  try {
-    return new RegExp(finalPattern, flags);
-  } catch (e) {
-    throw new InvalidRegexError(e.message);
-  }
-}
-
-export const TRegex = new t.Type<RegExp, string>(
-  "TRegex",
-  (s): s is RegExp => s instanceof RegExp,
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      try {
-        return t.success(inputPatternToRegExp(s));
-      } catch (err) {
-        if (err instanceof InvalidRegexError) {
-          return t.failure(s, [], err.message);
-        }
-
-        throw err;
-      }
-    }),
-  (s) => `/${s.source}/${s.flags}`,
-);
-
-// From io-ts/lib/PathReporter
-function stringify(v) {
-  if (typeof v === "function") {
-    return t.getFunctionName(v);
-  }
-  if (typeof v === "number" && !isFinite(v)) {
-    if (isNaN(v)) {
-      return "NaN";
-    }
-    return v > 0 ? "Infinity" : "-Infinity";
-  }
-  return JSON.stringify(v);
-}
-
-export class StrictValidationError extends Error {
-  private readonly errors;
-
-  constructor(errors: string[]) {
-    errors = Array.from(new Set(errors));
-    super(errors.join("\n"));
-    this.errors = errors;
-  }
-  getErrors() {
-    return this.errors;
-  }
-}
-
-const report = fold((errors: any): StrictValidationError | void => {
-  const errorStrings = errors.map((err) => {
-    const context = err.context.map((c) => c.key).filter((k) => k && !k.startsWith("{"));
-    while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1);
-
-    const value = stringify(err.value);
-    return value === undefined
-      ? `<${context.join("/")}> is required`
-      : `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`;
-  });
-
-  return new StrictValidationError(errorStrings);
-}, noop);
-
-export function validate(schema: t.Type<any>, value: any): StrictValidationError | null {
-  const validationResult = schema.decode(value);
-  return (
-    pipe(
-      validationResult,
-      fold(
-        () => report(validationResult),
-        () => null,
-      ),
-    ) || null
-  );
-}
-
-export function parseIoTsSchema<T extends t.Type<any>>(schema: T, value: unknown): t.TypeOf<T> {
-  const decodeResult = schema.decode(value);
-  if (isLeft(decodeResult)) {
-    throw report(decodeResult);
-  }
-  return decodeResult.right;
-}
-
-/**
- * Decodes and validates the given value against the given schema while also disallowing extra properties
- * See: https://github.com/gcanti/io-ts/issues/322
- */
-export function decodeAndValidateStrict<T extends t.HasProps>(
-  schema: T,
-  value: any,
-  debug = false,
-): StrictValidationError | any {
-  const validationResult = t.exact(schema).decode(value);
-  return pipe(
-    validationResult,
-    fold(
-      () => report(validationResult),
-      (result) => {
-        // Make sure there are no extra properties
-        if (debug) {
-          // tslint:disable-next-line:no-console
-          console.log(
-            "JSON.stringify() check:",
-            JSON.stringify(value) === JSON.stringify(result)
-              ? "they are the same, no excess"
-              : "they are not the same, might have excess",
-            result,
-          );
-        }
-        if (JSON.stringify(value) !== JSON.stringify(result)) {
-          const diff = deepDiff(result, value);
-          const errors = diff.filter((d) => d.kind === "N").map((d) => `Unknown property <${d.path.join(".")}>`);
-          if (errors.length) return new StrictValidationError(errors);
-        }
-
-        return result;
-      },
-    ),
-  );
-}
diff --git a/package-lock.json b/package-lock.json
index 6fe63dc2..d853c8db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
         "lint-staged": "^9.4.2",
         "prettier": "^2.8.4",
         "prettier-plugin-organize-imports": "^3.2.2",
+        "ts-toolbelt": "^9.6.0",
         "tsc-watch": "^6.0.4",
         "typescript": "^5.0.4"
       }
@@ -3483,6 +3484,12 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/ts-toolbelt": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
+      "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==",
+      "dev": true
+    },
     "node_modules/tsc-watch": {
       "version": "6.0.4",
       "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-6.0.4.tgz",
diff --git a/package.json b/package.json
index 0cec2093..0309d59c 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "lint-staged": "^9.4.2",
     "prettier": "^2.8.4",
     "prettier-plugin-organize-imports": "^3.2.2",
+    "ts-toolbelt": "^9.6.0",
     "tsc-watch": "^6.0.4",
     "typescript": "^5.0.4"
   },