CODEBASE: Fix lint errors 3 (#1758)

This is a really big refactor because it actually *fixes* a lot of the lint errors instead of disabling them.
This commit is contained in:
catloversg 2024-11-14 23:18:57 +07:00 committed by GitHub
parent 97ca8c5f5e
commit 75cf9c88b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 187 additions and 51 deletions

@ -42,4 +42,18 @@ module.exports = {
version: "detect",
},
},
overrides: [
/**
* Some enums are subsets of other enums. For example, UniversityLocationName contains locations of 3 universities.
* With each member, we refer to the respective LocationName's member instead of using a literal string. This usage
* is okay, but it triggers the "prefer-literal-enum-member" rule. This rule is not useful in this case, so we
* suppress it in NetscriptDefinitions.d.ts.
*/
{
files: ["src/ScriptEditor/NetscriptDefinitions.d.ts"],
rules: {
"@typescript-eslint/prefer-literal-enum-member": ["off"],
},
},
],
};

8
package-lock.json generated

@ -69,6 +69,7 @@
"@types/react-beautiful-dnd": "^13.1.5",
"@types/react-dom": "^17.0.21",
"@types/react-resizable": "^3.0.5",
"@types/sprintf-js": "^1.1.4",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"babel-jest": "^29.7.0",
@ -5124,6 +5125,13 @@
"@types/node": "*"
}
},
"node_modules/@types/sprintf-js": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/sprintf-js/-/sprintf-js-1.1.4.tgz",
"integrity": "sha512-aWK1reDYWxcjgcIIPmQi3u+OQDuYa9b+lr6eIsGWrekJ9vr1NSjr4Eab8oQ1iKuH1ltFHpXGyerAv1a3FMKxzQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",

@ -70,6 +70,7 @@
"@types/react-beautiful-dnd": "^13.1.5",
"@types/react-dom": "^17.0.21",
"@types/react-resizable": "^3.0.5",
"@types/sprintf-js": "^1.1.4",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"babel-jest": "^29.7.0",

@ -51,6 +51,7 @@ import { Sleeve } from "../PersonObjects/Sleeve/Sleeve";
import { autoCompleteTypeShorthand } from "./utils/terminalShorthands";
import { resolveTeamCasualties, type OperationTeam } from "./Actions/TeamCasualties";
import { shuffleArray } from "../Infiltration/ui/BribeGame";
import { objectAssert } from "../utils/helpers/typeAssertion";
export const BladeburnerPromise: PromisePair<number> = { promise: null, resolve: null };
@ -1403,9 +1404,10 @@ export class Bladeburner implements OperationTeam {
/** Initializes a Bladeburner object from a JSON save state. */
static fromJSON(value: IReviverValue): Bladeburner {
objectAssert(value.data);
// operations and contracts are not loaded directly from the save, we load them in using a different method
const contractsData = value.data?.contracts;
const operationsData = value.data?.operations;
const contractsData = value.data.contracts;
const operationsData = value.data.operations;
const bladeburner = Generic_fromJSON(Bladeburner, value.data, Bladeburner.keysToLoad);
// Loading this way allows better typesafety and also allows faithfully reconstructing contracts/operations
// even from save data that is missing a lot of static info about the objects.

@ -4,6 +4,7 @@ import { codingContractTypesMetadata } from "./data/codingcontracttypes";
import { Generic_fromJSON, Generic_toJSON, IReviverValue, constructorsForReviver } from "./utils/JSONReviver";
import { CodingContractEvent } from "./ui/React/CodingContractModal";
import { ContractFilePath, resolveContractFilePath } from "./Paths/ContractFilePath";
import { objectAssert } from "./utils/helpers/typeAssertion";
/* Contract Types */
export const CodingContractTypes = Object.fromEntries(codingContractTypesMetadata.map((x) => [x.name, x]));
@ -127,6 +128,7 @@ export class CodingContract {
/** Initializes a CodingContract from a JSON save state. */
static fromJSON(value: IReviverValue): CodingContract {
objectAssert(value.data);
// In previous versions, there was a data field instead of a state field.
if ("data" in value.data) {
value.data.state = value.data.data;

@ -1,6 +1,7 @@
import { CorpStateName } from "@nsdefs";
import { Generic_fromJSON, Generic_toJSON, IReviverValue, constructorsForReviver } from "../utils/JSONReviver";
import { stateNames } from "./data/Constants";
export class CorporationState {
// Number representing what state the Corporation is in. The number
// is an index for the array that holds all Corporation States

@ -100,12 +100,6 @@ export class GangMember {
}
getTask(): GangMemberTask {
// TODO unplanned: transfer that to a save file migration function
// Backwards compatibility
if ((this.task as any) instanceof GangMemberTask) {
this.task = (this.task as any).name;
}
if (Object.hasOwn(GangMemberTasks, this.task)) {
return GangMemberTasks[this.task];
}

@ -165,9 +165,9 @@ function fieldEquals(a: boolean[][], b: boolean[][]): boolean {
}
function generateEmptyField(difficulty: Difficulty): boolean[][] {
const field = [];
const field: boolean[][] = [];
for (let i = 0; i < Math.round(difficulty.height); i++) {
field.push(new Array(Math.round(difficulty.width)).fill(false));
field.push(new Array<boolean>(Math.round(difficulty.width)).fill(false));
}
return field;
}

@ -20,11 +20,12 @@ import {
GymType,
JobName,
JobField,
LiteratureName,
type LiteratureName,
LocationName,
ToastVariant,
UniversityClassType,
CompanyName,
type MessageFilename,
} from "@enums";
import { PromptEvent } from "./ui/React/PromptManager";
import { GetServer, DeleteServer, AddToAllServers, createUniqueRandomIp } from "./Server/AllServers";
@ -145,8 +146,19 @@ export const ns: InternalAPI<NSFull> = {
stock: NetscriptStockMarket(),
grafting: NetscriptGrafting(),
hacknet: NetscriptHacknet(),
sprintf: () => sprintf,
vsprintf: () => vsprintf,
sprintf:
(ctx) =>
(_format, ...args) => {
const format = helpers.string(ctx, "format", _format);
return sprintf(format, ...(args as unknown[]));
},
vsprintf: (ctx) => (_format, _args) => {
const format = helpers.string(ctx, "format", _format);
if (!Array.isArray(_args)) {
throw helpers.errorMessage(ctx, `args must be an array.`);
}
return vsprintf(format, _args);
},
scan: (ctx) => (_hostname) => {
const hostname = _hostname ? helpers.string(ctx, "hostname", _hostname) : ctx.workerScript.hostname;
const server = helpers.getServer(ctx, hostname);
@ -1163,7 +1175,8 @@ export const ns: InternalAPI<NSFull> = {
if (!path) return false;
if (hasScriptExtension(path)) return server.scripts.has(path);
if (hasTextExtension(path)) return server.textFiles.has(path);
if (path.endsWith(".lit") || path.endsWith(".msg")) return server.messages.includes(path as any);
if (path.endsWith(".lit") || path.endsWith(".msg"))
return server.messages.includes(path as LiteratureName | MessageFilename);
if (hasContractExtension(path)) return !!server.contracts.find(({ fn }) => fn === path);
const lowerPath = path.toLowerCase();
return server.programs.map((programName) => programName.toLowerCase()).includes(lowerPath);

@ -3,6 +3,7 @@ import { toNative } from "./toNative";
import libarg from "arg";
import { NetscriptContext } from "../Netscript/APIWrapper";
export type Schema = [string, string | number | boolean | string[]][];
type FlagType = StringConstructor | NumberConstructor | BooleanConstructor | StringConstructor[];
type FlagsRet = Record<string, ScriptArg | string[]>;
export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => FlagsRet {
@ -12,7 +13,7 @@ export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => Flag
if (!Array.isArray(schema)) throw new Error("flags schema passed in is invalid.");
const args: Record<string, FlagType> = {};
for (const d of schema) {
for (const d of schema as Schema) {
let t: FlagType = String;
if (typeof d[1] === "number") {
t = Number;
@ -24,13 +25,15 @@ export function Flags(ctx: NetscriptContext | string[]): (data: unknown) => Flag
const numDashes = d[0].length > 1 ? 2 : 1;
args["-".repeat(numDashes) + d[0]] = t;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const ret: FlagsRet = libarg(args, { argv: vargs });
for (const d of schema) {
for (const d of schema as Schema) {
if (!Object.hasOwn(ret, "--" + d[0]) || !Object.hasOwn(ret, "-" + d[0])) ret[d[0]] = d[1];
}
for (const key of Object.keys(ret)) {
if (!key.startsWith("-")) continue;
const value = ret[key];
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete ret[key];
const numDashes = key.length === 2 ? 1 : 2;
ret[key.slice(numDashes)] = value;

@ -82,14 +82,14 @@ export function NetscriptUserInterface(): InternalAPI<IUserInterface> {
setStyles: (ctx) => (newStyles) => {
const styleValidator: Record<string, string | number | undefined> = {};
assertObjectType(ctx, "newStyles", newStyles, styleValidator);
const currentStyles = { ...Settings.styles };
const currentStyles: Record<string, unknown> = { ...Settings.styles };
const errors: string[] = [];
for (const key of Object.keys(newStyles)) {
if (!(currentStyles as any)[key]) {
if (!currentStyles[key]) {
// Invalid key
errors.push(`Invalid key "${key}"`);
} else {
(currentStyles as any)[key] = newStyles[key];
currentStyles[key] = newStyles[key];
}
}

@ -203,6 +203,7 @@ export class PlayerObject extends Person implements IPlayer {
// Remove any invalid jobs
for (const [loadedCompanyName, loadedJobName] of Object.entries(player.jobs)) {
if (!isMember("CompanyName", loadedCompanyName) || !isMember("JobName", loadedJobName)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete player.jobs[loadedCompanyName as CompanyName];
}
}

@ -377,6 +377,7 @@ export function quitJob(this: PlayerObject, company: CompanyName, suppressDialog
}
}
}
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.jobs[company];
}

@ -10,6 +10,7 @@ import { scaleWorkStats } from "../../../Work/WorkStats";
import { getKeyList } from "../../../utils/helpers/getKeyList";
import { loadActionIdentifier } from "../../../Bladeburner/utils/loadActionIdentifier";
import { invalidWork } from "../../../Work/InvalidWork";
import { objectAssert } from "../../../utils/helpers/typeAssertion";
interface SleeveBladeburnerWorkParams {
actionId: ActionIdentifier & { type: BladeburnerActionType.General | BladeburnerActionType.Contract };
@ -98,6 +99,7 @@ export class SleeveBladeburnerWork extends SleeveWorkClass {
/** Initializes a BladeburnerWork object from a JSON save state. */
static fromJSON(value: IReviverValue): SleeveBladeburnerWork {
objectAssert(value.data);
const actionId = loadActionIdentifier(value.data?.actionId);
if (!actionId) return invalidWork();
value.data.actionId = actionId;

@ -7,6 +7,7 @@ import { Sleeve } from "../Sleeve";
import { scaleWorkStats, WorkStats } from "../../../Work/WorkStats";
import { Locations } from "../../../Locations/Locations";
import { isMember } from "../../../utils/EnumHelper";
import { objectAssert } from "../../../utils/helpers/typeAssertion";
export const isSleeveClassWork = (w: SleeveWorkClass | null): w is SleeveClassWork =>
w !== null && w.type === SleeveWorkType.CLASS;
@ -54,8 +55,13 @@ export class SleeveClassWork extends SleeveWorkClass {
/** Initializes a ClassWork object from a JSON save state. */
static fromJSON(value: IReviverValue): SleeveClassWork {
if (!(value.data.classType in Classes)) value.data.classType = "Computer Science";
if (!(value.data.location in Locations)) value.data.location = LocationName.Sector12RothmanUniversity;
objectAssert(value.data);
if (typeof value.data.classType !== "string" || !(value.data.classType in Classes)) {
value.data.classType = "Computer Science";
}
if (typeof value.data.location !== "string" || !(value.data.location in Locations)) {
value.data.location = LocationName.Sector12RothmanUniversity;
}
return Generic_fromJSON(SleeveClassWork, value.data);
}
}

@ -225,7 +225,12 @@ class BitburnerSaveObject {
let parsedSaveData;
try {
parsedSaveData = JSON.parse(decodedSaveData);
parsedSaveData = JSON.parse(decodedSaveData) as {
ctor: string;
data: {
PlayerSave: string;
};
};
} catch (error) {
console.error(error); // We'll handle below
}
@ -632,6 +637,7 @@ function evaluateVersionCompatibility(ver: string | number): void {
if (isNaN(intExp)) intExp = 0;
anyPlayer.exp.intelligence += intExp;
for (const field of removePlayerFields) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete anyPlayer[field];
}
for (const sleeve of anyPlayer.sleeves) {
@ -640,6 +646,7 @@ function evaluateVersionCompatibility(ver: string | number): void {
if (isNaN(intExp)) intExp = 0;
anySleeve.exp.intelligence += intExp;
for (const field of removeSleeveFields) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete sleeve[field];
}
}

@ -79,7 +79,10 @@ export const sanitizeTheme = (theme: IScriptEditorTheme): void => {
return;
}
for (const themeKey of getRecordKeys(theme)) {
if (typeof theme[themeKey] !== "object") delete theme[themeKey];
if (typeof theme[themeKey] !== "object") {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete theme[themeKey];
}
switch (themeKey) {
case "base":
if (!["vs-dark", "vs"].includes(theme.base)) theme.base = "vs-dark";

@ -13,6 +13,7 @@ import { currentNodeMults } from "../BitNode/BitNodeMultipliers";
import { IPAddress, isIPAddress } from "../Types/strings";
import "../Script/RunningScript"; // For reviver side-effect
import { objectAssert } from "../utils/helpers/typeAssertion";
/**
* Map of all Servers that exist in the game
@ -63,6 +64,7 @@ export function DeleteServer(serverkey: string): void {
for (const key of Object.keys(AllServers)) {
const server = AllServers[key];
if (server.ip !== serverkey && server.hostname !== serverkey) continue;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete AllServers[key];
break;
}
@ -100,6 +102,7 @@ export function AddToAllServers(server: Server | HacknetServer): void {
export const renameServer = (hostname: string, newName: string): void => {
AllServers[newName] = AllServers[hostname];
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete AllServers[hostname];
};
@ -188,13 +191,22 @@ export function initForeignServers(homeComputer: Server): void {
export function prestigeAllServers(): void {
for (const member of Object.keys(AllServers)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete AllServers[member];
}
AllServers = {};
}
export function loadAllServers(saveString: string): void {
AllServers = JSON.parse(saveString, Reviver);
const allServersData: unknown = JSON.parse(saveString, Reviver);
objectAssert(allServersData);
for (const [serverName, server] of Object.entries(allServersData)) {
if (!(server instanceof Server) && !(server instanceof HacknetServer)) {
throw new Error(`Server ${serverName} is not an instance of Server or HacknetServer.`);
}
}
// We validated the data above, so it's safe to typecast here.
AllServers = allServersData as typeof AllServers;
}
export function saveAllServers(): string {

@ -24,6 +24,7 @@ import lodash from "lodash";
import { Settings } from "../Settings/Settings";
import type { ScriptKey } from "../utils/helpers/scriptKey";
import { objectAssert } from "../utils/helpers/typeAssertion";
interface IConstructorParams {
adminRights?: boolean;
@ -293,6 +294,7 @@ export abstract class BaseServer implements IServer {
// RunningScripts are stored as a simple array, both for backward compatibility,
// compactness, and ease of filtering them here.
const result = Generic_toJSON(ctorName, this, keys);
objectAssert(result.data);
if (Settings.ExcludeRunningScriptsFromSave) {
result.data.runningScripts = [];
return result;
@ -313,8 +315,11 @@ export abstract class BaseServer implements IServer {
// Initializes a Server Object from a JSON save state
// Called by subclasses, not Reviver.
static fromJSONBase<T extends BaseServer>(value: IReviverValue, ctor: new () => T, keys: readonly (keyof T)[]): T {
objectAssert(value.data);
const server = Generic_fromJSON(ctor, value.data, keys);
if (value.data.runningScripts != null && Array.isArray(value.data.runningScripts)) {
server.savedScripts = value.data.runningScripts;
}
// If textFiles is not an array, we've already done the 2.3 migration to textFiles and scripts as maps + path changes.
if (!Array.isArray(server.textFiles)) return server;
@ -333,7 +338,7 @@ export abstract class BaseServer implements IServer {
// In case somehow there are previously valid filenames that can't be sanitized, they will go in a new directory with a note.
for (const script of oldScripts) {
// We're about to do type validation on the filename anyway.
if (script.filename.endsWith(".ns")) script.filename = (script.filename + ".js") as any;
if (script.filename.endsWith(".ns")) script.filename = (script.filename + ".js") as ScriptFilePath;
let newFilePath = resolveScriptFilePath(script.filename);
if (!newFilePath) {
newFilePath = `${newDirectory}script${++invalidScriptCount}.js` as ScriptFilePath;

@ -151,6 +151,7 @@ export function deleteStockMarket(): void {
export function initStockMarket(): void {
for (const stockName of Object.getOwnPropertyNames(StockMarket)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete StockMarket[stockName];
}

@ -18,7 +18,7 @@ export function exportScripts(pattern: string, server: BaseServer, currDir = roo
// Return an error if no files matched, rather than an empty zip folder
if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`);
const filename = `bitburner${
hasScriptExtension(pattern) ? "Scripts" : pattern.endsWith(".txt") ? "Texts" : "Files"
hasScriptExtension(pattern) ? "Scripts" : hasTextExtension(pattern) ? "Texts" : "Files"
}.zip`;
zip
.generateAsync({ type: "blob" })

@ -33,6 +33,7 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi
}
let flags: LSFlags;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
flags = libarg(
{
"-l": Boolean,

@ -4,8 +4,11 @@ import { matchScriptPathUnanchored } from "../../utils/helpers/scriptKey";
import libarg from "arg";
export function ps(args: (string | number | boolean)[], server: BaseServer): void {
let flags;
let flags: {
"--grep": string;
};
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
flags = libarg(
{
"--grep": String,

@ -18,8 +18,14 @@ export function runScript(path: ScriptFilePath, commandArgs: (string | number |
if (!script) return Terminal.error(`Script ${path} does not exist on this server.`);
const runArgs = { "--tail": Boolean, "-t": Number, "--ram-override": Number };
let flags;
let flags: {
_: ScriptArg[];
"--tail": boolean;
"-t": string;
"--ram-override": string;
};
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
flags = libarg(runArgs, {
permissive: true,
argv: commandArgs,
@ -42,7 +48,7 @@ export function runScript(path: ScriptFilePath, commandArgs: (string | number |
if (!server.hasAdminRights) return Terminal.error("Need root access to run script");
// Todo: Switch out arg for something with typescript support
const args = flags._ as ScriptArg[];
const args = flags._;
const singleRamUsage = ramOverride ?? script.getRamUsage(server.scripts);
if (!singleRamUsage) {

@ -23,7 +23,10 @@ export function tail(commandArray: (string | number | boolean)[], server: BaseSe
}
// Just use the first one (if there are multiple with the same
// arguments, they can't be distinguished except by pid).
LogBoxEvents.emit(candidates.values().next().value);
const next = candidates.values().next();
if (!next.done) {
LogBoxEvents.emit(next.value);
}
} else if (typeof commandArray[0] === "number") {
const runningScript = findRunningScriptByPid(commandArray[0]);
if (runningScript == null) {

@ -5,7 +5,7 @@ import { GetAllServers } from "../Server/AllServers";
import { parseCommand, parseCommands } from "./Parser";
import { HelpTexts } from "./HelpText";
import { compile } from "../NetscriptJSEvaluator";
import { Flags } from "../NetscriptFunctions/Flags";
import { Flags, type Schema } from "../NetscriptFunctions/Flags";
import { AutocompleteData } from "@nsdefs";
import libarg from "arg";
import { getAllDirectories, resolveDirectory, root } from "../Paths/Directory";
@ -280,6 +280,7 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi
_: [],
};
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
flags = libarg(runArgs, {
permissive: true,
argv: command.slice(2),
@ -299,9 +300,9 @@ export async function getTabCompletionPossibilities(terminalText: string, baseDi
scripts: [...currServ.scripts.keys()],
txts: [...currServ.textFiles.keys()],
enums: enums,
flags: (schema: unknown) => {
flags: (schema: Schema) => {
if (!Array.isArray(schema)) throw new Error("flags require an array of array");
pos2 = schema.map((f: unknown) => {
pos2 = schema.map((f) => {
if (!Array.isArray(f)) throw new Error("flags require an array of array");
if (f[0].length === 1) return "-" + f[0];
return "--" + f[0];

@ -1 +1,3 @@
declare module "acorn-jsx-walk";
declare module "acorn-jsx-walk" {
export function extend(base: any): void;
}

@ -1 +0,0 @@
declare module "sprintf-js";

@ -1,3 +1,4 @@
import { arrayAssert } from "../utils/helpers/typeAssertion";
import type { IReviverValue } from "../utils/JSONReviver";
// Versions of js builtin classes that can be converted to and from JSON for use in save files
@ -6,6 +7,7 @@ export class JSONSet<T> extends Set<T> {
return { ctor: "JSONSet", data: Array.from(this) };
}
static fromJSON(value: IReviverValue): JSONSet<any> {
arrayAssert(value.data);
return new JSONSet(value.data);
}
}
@ -16,6 +18,15 @@ export class JSONMap<K, __V> extends Map<K, __V> {
}
static fromJSON(value: IReviverValue): JSONMap<any, any> {
return new JSONMap(value.data);
arrayAssert(value.data);
for (const item of value.data) {
arrayAssert(item);
if (item.length !== 2) {
console.error("Invalid data passed to JSONMap.fromJSON(). Value:", value);
throw new Error(`An item is not an array with exactly 2 items. Its length is ${item.length}.`);
}
}
// We validated the data above, so it's safe to typecast here.
return new JSONMap(value.data as [unknown, unknown][]);
}
}

@ -2,13 +2,14 @@
import { ObjectValidator, validateObject } from "./Validator";
import { JSONMap, JSONSet } from "../Types/Jsonable";
import { loadActionIdentifier } from "../Bladeburner/utils/loadActionIdentifier";
import { objectAssert } from "./helpers/typeAssertion";
type JsonableClass = (new () => { toJSON: () => IReviverValue }) & {
fromJSON: (value: IReviverValue) => any;
fromJSON: (value: IReviverValue) => unknown;
validationData?: ObjectValidator<any>;
};
export interface IReviverValue<T = any> {
export interface IReviverValue<T = unknown> {
ctor: string;
data: T;
}
@ -87,16 +88,23 @@ export function Generic_toJSON<T extends Record<string, any>>(
* @returns The object */
export function Generic_fromJSON<T extends Record<string, any>>(
ctor: new () => T,
// data can actually be anything. We're just pretending it has the right keys for T. Save data is not type validated.
data: Record<keyof T, any>,
data: unknown,
keys?: readonly (keyof T)[],
): T {
objectAssert(data);
const obj = new ctor();
// If keys were provided, just load the provided keys (if they are in the data)
if (keys) {
for (const key of keys) {
/**
* The type of key is "keyof T", but the type of data is Record<string, unknown>. TypeScript won't allow us to use
* key as the index of data, so we need to typecast here.
*/
for (const key of keys as string[]) {
const val = data[key];
if (val !== undefined) obj[key] = val;
if (val !== undefined) {
// @ts-expect-error -- TypeScript won't allow this action: Type 'T' is generic and can only be indexed for reading.
obj[key] = val;
}
}
return obj;
}

@ -1,5 +1,15 @@
// Various functions for asserting types.
export class TypeAssertionError extends Error {
friendlyType: string;
constructor(message: string, friendlyType: string, options?: ErrorOptions) {
super(message, options);
this.name = this.constructor.name;
this.friendlyType = friendlyType;
}
}
/** Function for providing custom error message to throw for a type assertion.
* @param v: Value to assert type of
* @param assertFn: Typechecking function to use for asserting type of v.
@ -12,31 +22,47 @@ export function assert<T>(
try {
assertFn(v);
} catch (e) {
if (e instanceof TypeAssertionError) {
throw msgFn(e.friendlyType);
}
const type = typeof e === "string" ? e : "unknown";
throw msgFn(type);
}
}
/** Returns the friendlyType of v. arrays are "array" and null is "null". */
export function getFriendlyType(v: unknown): string {
function getFriendlyType(v: unknown): string {
return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
}
//All assertion functions used here should return the friendlyType of the input.
/** For non-objects, and for array/null, throws the friendlyType of v. */
export function objectAssert(v: unknown): asserts v is Partial<Record<string, unknown>> {
/** For non-objects, and for array/null, throws an error with the friendlyType of v. */
export function objectAssert(v: unknown): asserts v is Record<string, unknown> {
const type = getFriendlyType(v);
if (type !== "object") throw type;
if (type !== "object") {
console.error("The value is not an object. Value:", v);
throw new TypeAssertionError(
`The value is not an object. Its type is ${type}. Its string value is ${String(v)}.`,
type,
);
}
}
/** For non-string, throws the friendlyType of v. */
/** For non-string, throws an error with the friendlyType of v. */
export function stringAssert(v: unknown): asserts v is string {
const type = getFriendlyType(v);
if (type !== "string") throw type;
if (type !== "string") {
console.error("The value is not a string. Value:", v);
throw new TypeAssertionError(`The value is not an string. Its type is ${type}.`, type);
}
}
/** For non-array, throws the friendlyType of v. */
/** For non-array, throws an error with the friendlyType of v. */
export function arrayAssert(v: unknown): asserts v is unknown[] {
if (!Array.isArray(v)) throw getFriendlyType(v);
if (!Array.isArray(v)) {
console.error("The value is not an array. Value:", v);
const type = getFriendlyType(v);
throw new TypeAssertionError(`The value is not an array. Its type is ${type}.`, type);
}
}