开发者小王
5/11/2025
Hey TypeScript wizards! 👋 I'm stuck on a weird generic type issue and could really use your help. Here's what's going down:
I'm building this Example
class that uses generics to enforce config types, specifically requiring a min
number. Here's the basic setup:
type Config = { min?: number; max?: number; }; class Example<C extends Config> { constructor(private config: Readonly<C> = {} as C) {} // Builder method to set min value min<const N extends number>(min: N) { return new Example<C & { min: N }>({ ...this.config, min, }); } }
Everything works great EXCEPT when using the default empty constructor! 😤 Here's what I mean:
// This should error but doesn't! 😱 const broken: Example<{ min: 1 }> = new Example(); // These work as expected ✅ const works1 = new Example({ min: 1 }); const works2 = new Example().min(1); // These correctly error ✅ const fails1 = new Example({ min: 2 }); // Error (good!) const fails2 = new Example().min(2); // Error (good!)
I've tried:
{} as C
vs {} as Readonly<C>
(no difference)It's like the default parameter bypasses the generic type check entirely! Has anyone hit this before? I'm trying to build a type-safe builder pattern, but this loophole is killing me.
PS: Working on a library where this type safety is crucial, so any help would be massively appreciated! 🙏
Playground link if you want to poke around with it.
程序员小李
5/11/2025
Hey there! 👋 Oh man, I feel your pain with this generic type issue - I've wrestled with similar problems when building type-safe builders myself! It's like TypeScript is being too permissive when you least want it to be, right? 😅
Here's what's happening under the hood and how to fix it:
When you use new Example()
with a default parameter, TypeScript isn't enforcing the generic constraint because the default {} as C
is essentially telling the compiler "trust me, this empty object matches whatever generic type you want". It's like a sneaky type assertion bypass!
We can fix this by making the default constructor parameter more type-safe. Here's how I'd modify your class:
class Example<C extends Config> { constructor(private config: Readonly<C> = {} as Readonly<Config>) {} // ... rest remains the same }
Or even better, make the empty constructor a separate static method:
class Example<C extends Config> { private constructor(private config: Readonly<C>) {} // Factory method for empty config static create() { return new Example<Config>({}); } min<const N extends number>(min: N) { return new Example<C & { min: N }>({ ...this.config, min, }); } }
Now your broken case will correctly error:
const broken: Example<{ min: 1 }> = Example.create(); // Error! 🎉
as
assertions with generics - they often create these sneaky holes in your type safetyA common mistake is thinking extends
constraints will be enforced during construction - they're more about what can be passed in rather than what must be present. That's why the empty object slips through.
Builder patterns in TypeScript can be tricky, but when done right, they're incredibly powerful! Your approach with min<const N>
is actually really clever for literal type preservation. 👏
If you want to go even further with type safety, you could explore:
Hope this helps unblock you! Let me know if you want to dive deeper into any of these approaches. Happy coding! 🚀
#TypeScript #Generics #TypeSafety #BuilderPattern #FrontendDevelopment