TypeScript is Not TypeScript: The Trap of Generic
Something that should never happen in production actually happened.
Even though
synchronize: falsewas clearly set in our TypeORM configuration, the moment the server started, the database schema changed. Columns were dropped and recreated. Schema drift happened in real time.
I thought s_ome configuration must have been messed up._ But the real cause wasn’t a careless TypeORM setting or a NestJS mistake. It was something more subtle — and far more common.
It was a misunderstanding about what TypeScript actually is.
Here’s the main takeaway of this post:
TypeScript generics do absolutely nothing at runtime.
When you write dotenv files(.env) using the NestJS Config Module, it feels like you’re safely retrieving a boolean. But under the hood, this creates a dangerous illusion:
- Generics do not convert values
- Environment variables are always strings
- The string
'false'is truthy in JavaScript - So
falsequietly becomestrue
In this post, we’ll walk through why this happens by looking at real NestJS source code — and by separating what happens at compile time from what actually happens at runtime.
The Setup: NestJS, TypeORM, and a False Sense of Safety
The setup was straightforward:
- A NestJS server using TypeORM
- Configuration driven by environment variables
ConfigServiceused to inject TypeORM options
The code looked roughly like this:
TypeOrmModule.forRoot({
...
synchronize: configService.get<boolean>('DB_SYNCHRONIZE', false),
...
});
And the .env file clearly said:
DB_SYNCHRONIZE=false
There’s a generic <boolean>. There’s a default value of false. TypeScript is happy because I indicated generic type. The IDE shows no warnings.
Yet when the server started, TypeORM ran schema synchronization.
Somehow, synchronize evaluated to true.
At that point, there was only one real question:
“Where did
_true_come from?”
The Root Cause: Generics Don’t Exist at Runtime
To understand what was happening, I checked the actual implementation of ConfigService.get in the official @nestjs/config repository. Here’s the relevant part:
get<T = any>(
propertyPath: KeyOf<K>,
defaultValueOrOptions?: T | ConfigGetOptions,
options?: ConfigGetOptions,
): T | undefined {
const internalValue = this.getFromInternalConfig(propertyPath);
if (!isUndefined(internalValue)) {
return internalValue;
}
const validatedEnvValue = this.getFromValidatedEnv(propertyPath);
if (!isUndefined(validatedEnvValue)) {
return validatedEnvValue;
}
if (!this._skipProcessEnv) {
const processEnvValue = this.getFromProcessEnv(propertyPath);
if (!isUndefined(processEnvValue)) {
return processEnvValue;
}
}
return defaultValue as T;
}
Key Point #1: No Type Conversion Happens
There is zero logic here that converts a string into a boolean. When the value comes from process.env, this line tells the whole story:
return processEnvValue;
process.env always returns strings. That string is returned as-is.
Key Point #2: Generics and as T Are Compile-Time Only
This line might look reassuring:
return defaultValue as T;
But as T disappears completely after compilation.
What really happens:
- Compile time (TypeScript): “Trust me, this function returns a boolean.”
- Runtime (JavaScript): “Here’s the string
'false'. Good luck.”
So you end up with a value that:
- Is typed as
boolean - Is actually a string
And in JavaScript:
if ('false') {
// This runs
}
At that moment, synchronize: false is effectively ignored.
The Obvious Fix — and Why NestJS Didn’t Choose It
My first instinct was simple:
“If you know the generic type, why not convert the value automatically?”
For example, infer the type from the default value:
if (typeof defaultValue === 'boolean') {
return (processEnvValue === 'true') as unknown as T;
}
With this approach, get('DB_SYNCHRONIZE', false) would behave exactly as expected.
But, So Why Didn’t the NestJS Team Do This?
Based on the code and design, a few reasons stand out (this is an informed guess):
- Implicit magic is dangerous: A value like
'01012345678'silently becoming a number can destroy data - Strings are sometimes intentional: Not every
'true'or'false'is meant to be a boolean - Clear responsibility boundaries: Environment variables are strings and parsing and validation belong to tools like
JoiorZod
And NestJS already provides the intended solution:
ConfigModule.forRoot({
validationSchema: Joi.object({
DB_SYNCHRONIZE: Joi.boolean().default(false),
}),
});
Only when values go through validatedEnv does get<boolean>() actually return a real boolean.
Final Thoughts
The lesson here is clear— but important:
TypeScript is just a fancy mask of JavaScript
TypeScript changes nothing how JavaScript runs. Generics are not safety nets. In configuration code, they can easily become comforting lies.
If you’re using get<boolean>(), it’s worth stopping for a moment and asking:
“Is this really a boolean — or just a string pretending to be one?”