"TypeError: method is not a function" when invoking methods on objects deserialized from a JSON file

1 week ago 10
ARTICLE AD BOX

I'm working on a project from Roadmap.sh with TypeScript (v5.9.3) running on Bun (v1.3.3 on a Linux x64 VM). Currently I'm writing the data access logic, implemented using the Repository pattern, but I'm running into problems every time I call certain methods on my Task class.

This is the code for the repository:

export class JsonFileTaskRepository implements TaskRepository { readonly backend: FileBackend; readonly idSequence: TaskIdSequence; constructor(backend: FileBackend, idSequence: TaskIdSequence) { this.backend = backend; this.idSequence = idSequence; } public async all(status?: TaskStatus): Promise<Array<Task>> { const tasks = await this.readStream(); if (!status) { return tasks; } return tasks.filter((task) => task.hasStatus(status)); } public async byId(id: TaskId): Promise<Task | undefined> { const tasks = await this.readStream(); return tasks.find((task) => task.hasId(id)); } public async add(task: Task): Promise<void> { const tasks = await this.readStream(); tasks.push( task.identified(await this.idSequence.nextId()) ); await this.backend.write(JSON.stringify(tasks)); } public async update(id: TaskId, newTask: Task): Promise<void> { const tasks = await this.readStream(); if (!tasks.find((task) => task.hasId(id))) { throw new Error(`task with id=${id} not found`); } const newTasks = tasks.filter((task) => !task.hasId(id)); newTasks.push(newTask.identified(id)); await this.backend.write(JSON.stringify(newTasks)); } public async remove(id: TaskId): Promise<void> { const tasks = await this.readStream(); if (!tasks.find((task) => task.hasId(id))) { throw new Error(`task with id=${id} not found`); } const newTasks = tasks.filter((task) => !task.hasId(id)); await this.backend.write(JSON.stringify(newTasks)); } public async latestId(): Promise<number | undefined> { return await this.idSequence.latestId(); } private async readStream(): Promise<Array<Task>> { const tasks: Array<Task> = await this.backend.json(); return tasks; } }

This is the Task class:

export type TaskId = number; export enum TaskStatus { TODO = "todo", IN_PROGRESS = "in-progress", DONE = "done", } export class Task { readonly id?: TaskId; readonly name: string; readonly status: TaskStatus; readonly creationTime: Date; readonly lastModificationTime: Date; public constructor(props: {id?: TaskId, name: string, status: TaskStatus, creationTime: Date, lastModificationTime: Date}) { this.id = props.id; this.name = props.name; this.status = props.status; this.creationTime = props.creationTime; this.lastModificationTime = props.lastModificationTime; } public static fromName(name: string): Task { const now = new Date(); return new Task({ name, status: TaskStatus.TODO, creationTime: now, lastModificationTime: now, }); } public identified(id: TaskId): Task { return new Task({ id, name: this.name, status: this.status, creationTime: this.creationTime, lastModificationTime: this.lastModificationTime, }); } public withName(newName: string): Task { return new Task({ id: this.id, name: newName, status: this.status, creationTime: this.creationTime, lastModificationTime: new Date(), }); } public markedAsInProgress(): Task { return new Task({ id: this.id, name: this.name, status: TaskStatus.IN_PROGRESS, creationTime: this.creationTime, lastModificationTime: new Date(), }) } public markedAsDone(): Task { return new Task({ id: this.id, name: this.name, status: TaskStatus.DONE, creationTime: this.creationTime, lastModificationTime: new Date(), }); } public hasId(id: TaskId): boolean { return this.id === id; } public hasStatus(status: TaskStatus): boolean { return this.status === status; } public toString(): string { return `${this.name} (${this.status}) (ID: ${this.id})` } }

And these are the TaskIdSequence and FileBackend classes:

import { appendFile } from "node:fs/promises"; import type { Task } from "./model"; export class JsonFileTaskIdSequence implements TaskIdSequence { readonly backend: FileBackend; constructor(backend: FileBackend) { this.backend = backend; } public async nextId(): Promise<TaskId> { const latestId = await this.latestId(); if (!latestId) { return 1; } return latestId + 1; } public async latestId(): Promise<TaskId | undefined> { return this.backend.json() .then((items: Array<Task>) => Math.max(...items.map((item) => item.id || -1))) .then((result) => (result < 0) ? undefined : result) .catch((error) => { throw error }); } } export class FileBackend { readonly path: string; constructor(path: string) { this.path = path; } public async json(): Promise<Array<Task>> { let file = Bun.file(this.path); if (!await file.exists()) { await appendFile(this.path, "[]"); } return file.json(); } public async write(data: string) { let file = Bun.file(this.path) if (!await file.exists()) { await appendFile(this.path, ""); } return file.write(data); } }

When I call the update() or remove() methods on the repository class, I get an error message like this, complaining about either hasId() or hasStatus() from the Task class:

56 | return tasks.filter((task) => task.hasStatus(status)); 57 | } 58 | 59 | public async byId(id: TaskId): Promise<Task | undefined> { 60 | const tasks = await this.readStream(); 61 | return tasks.find((task) => task.hasId(id)); ^ TypeError: task.hasId is not a function. (In 'task.hasId(id)', 'task.hasId' is undefined) at <anonymous> (/home/dev/workspaces/ts/taskr/src/taskr/repository.ts:61:42) at find (1:11) at async deleteTask (/home/dev/workspaces/ts/taskr/src/taskr/service.ts:79:44) at async doCommand (/home/dev/workspaces/ts/taskr/src/index.ts:58:27)

Interestingly, my editor (VS Code) doesn't see anything wrong with my code, and if I replace these calls with a raw property comparison like tasks.find((task) => task.id === id) Bun is fine with my code. But then it complains about the markedAsInProgress() and markedAsDone() methods as well, which are called from my TaskService class. Trying to build the code as a JavaScript bundle with bun build and then running it didn't solve the problem, either.

I've been looking for similar issues but couldn't find anything specific enough. Is this specific to Bun? Could it be related to the type erasure performed by the TS compiler?

Read Entire Article