TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
108: Generic Defaults
I see this all the time when developers first encounter generic defaults: they mistake them for generic constraints. They'll write <T = string> and assume they've just told TypeScript, "T must be a string, or something that behaves like one."
Defaults are not Constraints
If you think a default value forces a type, you're in for a surprise. Let's look at a StateContainer example. Imagine we're building a simple wrapper for a piece of application state.
type StateContainer<T = string> = {
value: T;
lastUpdated: number;
};
// This works fine. T is not provided, so it falls back to string.
const nameState: StateContainer = {
value: "Alice",
lastUpdated: Date.now(),
};
// This ALSO works fine.
// I'm explicitly telling it T is a number.
const ageState: StateContainer<number> = {
value: 30,
lastUpdated: Date.now(),
};
If = string were a constraint, the ageState example would have thrown a compiler error because a number isn't a string. But as you can see, the default is completely ignored the moment you provide an explicit type argument. It's not a guardrail; it's a fallback.
Providing a Sensible Fallback for the Lazy (or the Common)
So, why bother? In my experience, generic defaults are most useful when you have a "standard" case that happens 90% of the time, but you don't want to lock the door for the other 10%.
Take a typical API response wrapper. Most of the time, your API might return a simple string message or a basic payload, but occasionally it returns a complex object. Instead of forcing the consumer of your type to write <string> every single time they call a basic endpoint, you can just bake it in.
interface ApiResponse<T = string> {
data: T;
status: number;
error?: string;
}
// No need to specify <string> here. It's the default.
const simpleResponse: ApiResponse = {
data: "Success",
status: 200,
};
// But for the complex endpoint, we can still be specific.
interface UserProfile {
username: string;
email: string;
}
const userResponse: ApiResponse<UserProfile> = {
data: { username: "dev_guy", email: "dev@example.com" },
status: 200,
};
It makes your API much cleaner to use. You're essentially saying, "Here is the most likely type you'll need, but feel free to override it if your needs are more exotic."
Combining Defaults with Constraints
Now, if you actually did want to constrain the type while still providing a default, you can do both. This is where things get powerful. You use extends for the rule and = for the fallback.
// T must be an object (constraint),
// but if none is provided, assume it's a basic record (default).
interface ConfigContainer<T extends object = Record<string, unknown>> {
config: T;
env: string;
}
const basicConfig: ConfigContainer = {
config: { timeout: 5000 }, // Valid: matches Record<string, unknown>
env: "development",
};
const strictConfig: ConfigContainer<{ port: number }> = {
config: { port: 8080 }, // Valid: matches the constraint 'object'
env: "production",
};
// Error: number is not an object. The constraint wins.
// const badConfig: ConfigContainer<number> = { ... };
I find this combination particularly useful for plugin systems or middleware where you want to ensure a minimum structure exists, but you don't want to force the user to define a custom interface for every single trivial implementation.
📋 Practical Task
Implement a Flexible DataStore Wrapper
You are building a DataStore utility. The store should be generic, allowing users to specify the type of data it holds. However, most of the stores in your app hold simple string values.
Your Task:
- Create a generic interface
DataStore<T>. - Apply a generic default so that if no type is provided,
Tdefaults tostring. - Add a generic constraint to ensure that
Tmust be either astring, anumber, or aboolean(Hint: Use a union type with theextendskeyword). - Implement two variables:
commonStore: Uses the default type without explicitly declaring it.numericStore: Explicitly declares that it stores numbers.
Ensure that attempting to create a DataStore<object> results in a TypeScript error.
There are no comments for now.