How Typescript fooled me in 2024

… due to legacy code and lazy practices :-)

How Typescript fooled me in 2024

How TypeScript Fooled Me in 2024

Thanks to legacy code and lazy practices.

In this post, we are going to learn (or refresh) some basic TypeScript knowledge and see how subtle changes in third-party libraries can cause big surprises.

The problem

We’ve added interfacing with Stripe for payments inside our new product. While testing the changes I got a weird error from Stripe, complaining about a value of type buffer not being a string which clearly was supposed to be a string. As it worked perfectly on my colleague's machine before updating node packages, there might have been a change in the Stripe API to suddenly be more strict about values that are given to their API endpoints.

So let’s continue to build a showcase of the issue.

Sample Project

In general, we use NestJS with TypeScript for our backends these days.
Most of the time we use MongoDB for our databases and work with Mongoose. In order to showcase what I fell victim to, while developing the latest features for our brand-new product Croppy, we’ll set up a sample project.

Initial setup

We will rely on packages installed as per my MacBook Setup Guide, so feel free to check it out if you haven’t seen it yet.

nvm use lts/iron 
npm i -g @nestjs/cli 
nest new --strict tsfoobar 
yarn add @nestjs/mongoose mongoose 
nest g resource

Note: NestJS commands are interactive. I chose yarn as a package manager when creating the project, called our resource "cats", went with a REST API resource, and chose to generate CRUD operations.

Adding a MongoDB connection

As per the docs, we add the MongoDB connection to the app module like this:

import { Module } from '@nestjs/common'; 
import { MongooseModule } from '@nestjs/mongoose'; 
import { AppController } from './app.controller'; 
import { AppService } from './app.service'; 
import { CatsModule } from './cats/cats.module'; 
 
@Module({ 
  imports: [MongooseModule.forRoot('mongodb://nest:nest@127.0.0.1:27017/nest'), 
            CatsModule], 
  controllers: [AppController], 
  providers: [AppService], 
}) 
export class AppModule {}

Adding a Mongoose model (as per our legacy code)

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 
import { HydratedDocument, Document } from 'mongoose'; 
 
@Schema() 
export class Cat { 
  _id: Types.ObjectId; 
 
  @Prop() 
  name: string; 
 
  @Prop() 
  age: number; 
 
  @Prop() 
  breed: string; 
} 
 
export type CatDocument = Cat & Document; 
export const CatSchema = SchemaFactory.createForClass(Cat);

Adding a service

import { Model } from 'mongoose'; 
import { Injectable } from '@nestjs/common'; 
import { InjectModel } from '@nestjs/mongoose'; 
import { CreateCatDto } from './dto/create-cat.dto'; 
import { Cat } from './schemas/cat.schema'; 
 
@Injectable() 
export class CatsService { 
  constructor(@InjectModel(Cat.name) private catModel: Model<Cat>) {} 
 
  async create(createCatDto: CreateCatDto): Promise<Cat> { 
    const createdCat = new this.catModel(createCatDto); 
    return createdCat.save(); 
  } 
 
  async findAll() { 
    return this.catModel.find().exec(); 
  } 
 
  async testCall(catId: string): Promise<string> { 
    console.log(catId); 
    return catId; 
  } 
}

Adding a controller

import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; 
import { CatsService } from './cats.service'; 
import { CreateCatDto } from './dto/create-cat.dto'; 
import { CatDocument } from './schemas/cat.schema'; 
 
@Controller('cats') 
export class CatsController { 
  constructor(private readonly catsService: CatsService) {} 
 
  @Post() 
  create(@Body() createCatDto: CreateCatDto) { 
    return this.catsService.create(createCatDto); 
  } 
 
  @Get() 
  findAll() { 
    return this.catsService.findAll(); 
  } 
 
  @Get("surprise") 
  async surprise() { 
    const cats = await this.catsService.findAll(); 
    const firstcat:CatDocument = cats[0]; 
    this.catsService.testCall(firstcat._id); 
    return cats[0]; 
  } 
}

You can find the full project on GitHub: https://github.com/a1rwulf/tsfoobar

Surprise Surprise

Ok, so we have the basic setup.

People with profound knowledge of TypeScript, NestJS, and Mongoose might have already spotted the issue at hand. But here is what caused a pretty huge what the heck moment for me:

WAT!?
Several questions hit my mind at the very same time.

  • How can the _id not be a string when printed inside testCall, the param type is clearly supposed to be string?
  • If it isn’t, why does the linter not yell at me?
  • Maybe it’s a linter bug, but why does it compile then?
  • OK, I made an obvious mistake, according to the schema ObjectId is the correct type, but then again what about all the questions above?

Research and Resolution

I mean once I carefully checked our code, I clearly expected _id to be of type ObjectId as this is what we specified in our schema file.

"ObjectId probably has some magic implemented to make TypeScript infer it as a string?" was what we thought when discussing the issue at hand.

After digging deeper the real issue was revealed.

Even though you would expect that Mongoose as a MongoDB ORM would treat _id in a Document to be of type ObjectId, it really is a generic that defaults to any.

What’s up with ANY?

So one thing you need to know is that any unlike unknown just disables type-checking for the variable at hand. Using any means you can assign any value to your variable, but also you can assign your variable to any other variable no matter what the target type is — that's why you think you have a string in your hand inside of testCall while it is not the case. Clearly, I had some gaps in my TypeScript knowledge (and you might have too).

This thing bugged me so much that I continued researching for a bit and found several problems in our code that revealed an unfortunate chain of disasters, that ultimately led to my situation.

Solution

The solution for my problem in this case was very simple. I had to create my Mongoose schema according to the latest docs and use:

export type CatDocument HydratedDocument<Cat>

instead of:

export type CatDocument = Cat & Document;

Learnings

Here are a bunch of issues I found in our project setup while researching the above issue:

  • We had “noImplicitAny” set to false in our tsconfig because some colleagues got annoyed with the need to always explicitly cast to any from libraries that do not deliver types.
  • The code we used for our schemas, was originally written by a contractor who used the legacy version from a few years ago: https://github.com/nestjs/docs.nestjs.com/pull/2517
  • Pay attention to details and dig deep if you find issues that you do not immediately understand, if it doesn’t help to find a better solution it will at least teach you a ton which never hurts.

How to do better next time

  • Use “strict” mode for TypeScript if you want as much help from TypeScript as possible, to not make stupid type-related mistakes
  • Check your code for best practices regularly
  • Try to keep up with changes in third-party libraries that are essential for your own code
  • Pay attention to details

Sidenote:
Mongoose had a similar issue in their HydratedDocument type which is the current default recommendation:
https://github.com/Automattic/mongoose/issues/11085
I’m still not sure why the Document type doesn’t have a similar implementation or defaults to type _id as ObjectId by default instead of any, but let’s see if their maintainers will answer my question about that.


Thanks for reading!
If you liked this post, please subscribe to receive more useful content.
Go to https://seadev-studios.com for more infos about our company.