Typescript TypeError: someObject.someMethod() is not a function
June 21st 2022
Recently, I encountered a Type Error when testing a local storage solution: a class object was failing to access its methods.
TypeError: someObject.someMethod() is not a function
After several days, the one and only Nick Gartmann solved the issue while pairing with me (shoutout Nick). I struggled to find any helpful articles while googling around, so I’m writing this to ease someone else’s stress.
Laying Out the Problem
Let’s imagine I have an object class Product that holds information nested in its attributes like ProductData.
export interface ProductData {
id: string
name: string
}
export class Product {
data: ProductData
constructor (data) {
this.data = data
}
getId(): string {
return this.data.id
}
}
Now I want to store a list of Products in some kind of local storage (i.e. my browser, @ionic/storage, @capacitor/storage). To make this easy, I’ll create a class to save & access an array of Products: ProductStorage.
export class ProductStorage {
save (products: Product[]) {
window.localStorage.setItem('products', JSON.stringify(products))
}
fetch (): Product[] {
return window.localStorage.getItem('products') as Product[]
}
}
Note I JSON.stringify() the Product list because local storage only works with strings. To satisfy Typescript’s type checking, I include “as Product[]” because I am confident in the data model coming back to me.
Next, I write a test like so:
const product1: Product = new Product({ id: '1', name: 'Product 1' } as ProductData)
const product2: Product = new Product({ id: '1', name: 'Product 1' } as ProductData)
describe('ProductStorage', () => {
test('save and fetch', async () => {
const productStorage: ProductStorage = new ProductStorage()
productStorage.save([product1, product2])
const result: Product[] = productStorage.fetch()
const resultIds: string[] = result.map((product: Product) => product.getId())
expect(resultIds).toMatchObject(['1', '2'])
})
})
and BOOM! Type Error!
TypeError: product.getId() is not a function
But you saw the Product class – getId() is certainly a function.
Yet, to my surprise, accessing the attribute directly on the Product object proves successful – test passes?
const resultIds: string[] = result.map((product: Product) => product.data.id)
So, why isn’t the getId() method working if the attribute exists?
The Revelation
It turns out that when we perform JSON.stringify() on a class object, it does a great job of storing attributes, but it won’t store the class functions. This information is actually provided right here in the documentation too … if only I’d seen this a few days ago, sigh.
undefined, Functions, and Symbols are not valid JSON values. If any such values are encountered during conversion they are either omitted (when found in an object) or changed to null (when found in an array). JSON.stringify() can return undefined when passing in “pure” values like JSON.stringify(function() {}) or JSON.stringify(undefined).
So the Product class is getting its methods chopped off when it’s stored, rude. AND Type checking didn’t alert us to any obscurities because we were casting the fetched result over with “as”. Typescript raised no issues despite the prototype of the class object missing (the methods). Typescript has played me for a fool yet again.
My Solution
I can’t force local storage to keep my methods, so I’m going to explicitly store Product objects without any class methods. Then I’ll re-create each Product’s class object upon pulling the data out of local storage.
I’ll start by adding explicit conversion methods to the Product class. I want future developers to know exactly what’s going on here (and me in a week or two). Part of this involves defining a new type, ProductStorageFormat, with the same attributes as Product, but no methods. This tells any future developers exactly what is being stored.
export interface ProductStorageFormat {
data: ProductData
}
export class Product {
data: ProductData
constructor (data) {
this.data = data
}
getId(): string {
return this.data.id
}
toStorageFormat(): ProductStorageFormat {
return this as ProductStorageFormat
}
static fromStorageFormat(storedProduct: ProductStorageFormat): Product {
return new Product(storedProduct.data)
}
}
A few notes
toStorageFormat() does not manipulate the object, but rather makes it clear that we’re only expecting to have that data type represented by ProductStorageFormat available in local storage. fromStorageFormat(…) is static because we want this to be available when pulling the data out of storage i.e. when it won’t have non-static methods available.
Neither static methods nor static properties can be called on instances of the class. Instead, they’re called on the class itself.
Now I’ll rework ProductStorage to use these conversion methods to map data going into and out of storage.
export class ProductStorage {
save (products: Product[]) {
window.localStorage.setItem('products', JSON.stringify(products.map((p) => p.toStorageFormat())))
}
fetch (): Product[] {
return window.localStorage.getItem('products').map(Product.fromStorageFormat)
}
}
There we have it! Typescript may have led me astray this time, but I’ll have my vengeance one day.
I hope this was helpful. If you have another proposed solution to this, or any comments whatsoever, drop us a tweet @RokkinCat on Twitter. Most of our employees are pretty active on their personal Twitter, and we appreciate the discourse!