Extends never
🗓 Tue, 03 Jun 2025 00:00:00 GMTExamines why keyof T extends never in TypeScript's DeepReadonly is problematic—it fails on union types. Shows that mapped types handle primitives automatically, making the conditional check unnecessary and error-prone..
When examining TypeScript utility types, you might encounter this implementation of DeepReadonly
:
type DeepReadonly<T> = keyof T extends never ? T : { readonly [k in keyof T]: DeepReadonly<T[k]> };
The keyof T extends never
check might seem puzzling at first. Let's break down what it does and why it's problematic.
What Does keyof T extends never
Actually Check?
The never
type is TypeScript's bottom type — a type with no possible values. When we check keyof T extends never
, we're asking: "Does the type T
have zero known keys?"
Here's how it behaves with different types:
type HasNoKeys<T> = keyof T extends never ? true : false;
type Test1 = HasNoKeys<{ a: string }> // false - has key "a"
type Test2 = HasNoKeys<{}> // true - empty object
type Test3 = HasNoKeys<object> // true - no specific keys
type Test4 = HasNoKeys<string> // false - has methods like "charAt"
type Test5 = HasNoKeys<{ a: string } | { b: string }> // true - no common keys!
The last example reveals the problem: union types with no common keys return true
because keyof
only returns keys that exist on all members of the union.
Important
keyof
returns only the keys that exist on all members of an union
Why This Breaks DeepReadonly ?
This behavior causes DeepReadonly
to fail on union types:
type DeepReadonly<T> = keyof T extends never ? T : { readonly [k in keyof T]: DeepReadonly<T[k]> };
type BrokenResult = DeepReadonly<{ a: string } | { b: string }>;
// Result: { a: string } | { b: string } - NOT readonly!
declare const obj: BrokenResult;
if ("a" in obj) {
obj.a = ""; // No error! Should be readonly but isn't
}
Since the union has no common keys, keyof T extends never
returns true
, so we get the original type back without any readonly
modifiers.
The Right Way: Skip the Check Entirely
Mapped types already handle primitives correctly without explicit checks:
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
Here's why this works:
- Primitives are preserved:
{ readonly [K in keyof string]: ... }
just returnsstring
- Unions distribute automatically: Mapped types process each union member separately
- No edge cases: Works correctly for all input types
type TestMapped<T> = { [K in keyof T]: "transformed" };
type Result1 = TestMapped<{ a: string }>; // { a: "transformed" }
type Result2 = TestMapped<string>; // string (unchanged)
type Result3 = TestMapped<{ a: string } | { b: string }>;
// { a: "transformed" } | { b: "transformed" }
extends object
If you want to explicitly distinguish between objects and primitives, use extends object
:
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
This works because:
extends object
creates a distributive conditional type- Union types are automatically split, processed individually, then recombined
- More explicit about the object vs. primitive distinction
Key Takeaways
keyof T extends never
is unreliable - it fails on union types with no common keys- Mapped types are self-limiting - they naturally handle primitives without explicit checks
- Union distribution is automatic - both mapped types and distributive conditionals handle unions correctly
- Simpler is better - the version without any conditional check works best for
DeepReadonly
The original extends never
check was likely an attempt to create a base case for recursion, but TypeScript's type system handles the recursion termination automatically through mapped types.