07-10-edge-cases-in-type-narrowing
Edge Cases in Type Narrowing in TypeScript:
Let's proceed with the concise code examples for the TypeScript key points you've highlighted:
Type Guards Might Not Persist:
Within nested functions or callbacks, TypeScript might not preserve type narrowing. This can cause a variable to revert to a broader type.
- Example:
function doSomething(value: string | number) {
if (typeof value === 'string') {
// TypeScript knows value is a string here
return function() {
// Inside this function, TypeScript might not remember 'value' is a string
console.log(value.trim()); // Error if 'value' is not a string
};
}
}
- Example:
Narrowing with
typeof
:When using
typeof
for type guards, TypeScript only narrows tostring
,number
,bigint
,boolean
,symbol
,undefined
,object
, andfunction
. It won't recognize custom types.- Example:
function processValue(value: unknown) {
if (typeof value === 'string') {
// TypeScript narrows 'value' to string
console.log(value.toUpperCase());
}
}
- Example:
Narrowing with
instanceof
:The
instanceof
type guard only works with classes or functions created with a constructor. It doesn't work with interfaces since they don't exist at runtime.- Example:
class MyClass {}
function checkInstance(object: any) {
if (object instanceof MyClass) {
// TypeScript narrows 'object' to MyClass instance
console.log('It is an instance of MyClass');
}
}
- Example:
Nullish Coalescing Operator (
??
) and Optional Chaining (?.
):These operators can introduce unexpected behaviors when used in type narrowing because they handle
null
andundefined
differently than other falsy values.Example:
type MaybeNumber = {
num?: number | null;
};
const example: MaybeNumber = { num: null };
const number = example.num ?? 10; // 'number' is type 'number', but TypeScript doesn't narrow 'example.num' to 'number'
Narrowing and Union Types:
When narrowing union types, be cautious of types that have common properties, as TypeScript may not narrow the type down to a single possibility.
Example:
type Cat = { meow: boolean };
type Dog = { bark: boolean };
type Pet = Cat | Dog;
function petSounds(pet: Pet) {
if ('meow' in pet) {
// TypeScript knows 'pet' could be Cat, but it's still Pet because Dog might also have 'meow'
console.log('It may meow');
}
}
Array
includes
Method:Using
.includes()
to narrow types from a union can be tricky because TypeScript does not infer that the element is of a specific type from the union.Example:
const pets = ['cat', 'dog', 'parrot'] as const;
type Pet = typeof pets[number]; // 'cat' | 'dog' | 'parrot'
function isCat(pet: Pet) {
if (pets.includes(pet)) {
// TypeScript can't infer 'pet' is specifically 'cat'
console.log('It is a pet', pet);
}
}
Discriminated Unions:
Relying on a common property (discriminant) is a robust way to narrow union types, but only if the discriminant's values are unique across members.
Example:
```typescript
type Square = { kind: 'square'; size: number };
type Circle = { kind: 'circle'; radius: number };
type Shape = Square | Circle;
function getArea(shape: Shape) {
if (shape.kind === 'square') {
// TypeScript narrows 'shape' to Square
return shape.size * shape.size;
}
}
```Let's continue with the code examples for the remaining TypeScript key points:
Type Narrowing with Custom Type Guards:
Custom type guards (
is
keyword) can precisely narrow types, but they need to be correctly defined to avoid runtime errors.Example:
function isString(value: any): value is string {
return typeof value === 'string';
}
function processValue(value: any) {
if (isString(value)) {
// TypeScript narrows 'value' to string based on the custom type guard
console.log(value.toUpperCase());
}
}
Narrowing with Enums:
Type narrowing with enums can be unpredictable if the enum is number-based because TypeScript might also accept raw numbers where the enum is expected.
Example:
enum Status {
New,
InProgress,
Done,
}
function processStatus(status: Status) {
if (status === Status.Done) {
// TypeScript narrows 'status' to Status.Done
console.log('Completed');
}
}
processStatus(2); // Works, but passing any number would also work, which can be unsafe
Aliasing Primitive Types:
When primitive types are aliased, TypeScript may not narrow them as expected because the alias could technically be a different type.
- Example:
type MyStringType = string;
function isString(value: MyStringType | number) {
// Even though MyStringType is an alias for string, TypeScript treats it as a unique type
if (typeof value === 'string') {
// TypeScript does not narrow 'value' to 'MyStringType'
console.log('It is a string');
}
}
- Example:
Assertions and Narrowing:
Assertions (
as
keyword) can interfere with type narrowing, as they forcefully tell the compiler to treat an entity as a certain type.- Example:
function forceString(value: any) {
const strValue = value as string;
// TypeScript is told to assume 'strValue' is a string, even if it's not
console.log(strValue.trim());
}
- Example:
Function Overloads:
TypeScript looks at function overloads from top to bottom. Incorrectly ordered overloads can affect type narrowing.
- Example:
function overloadedFunction(value: string): void;
function overloadedFunction(value: number): void;
function overloadedFunction(value: any): void {
if (typeof value === 'string') {
// If the string overload is above the number overload, TypeScript will narrow to string here
console.log('String:', value);
}
}
- Example:
Literal Types and Narrowing:
When dealing with literal types, TypeScript might widen the literal to its base type unless you use
const
assertions.- Example:
let literalType = 'specificValue' as const; // 'specificValue' is treated as a literal type, not string
function checkLiteral(value: 'specificValue' | 'otherValue') {
if (value === literalType) {
// TypeScript recognizes 'value' as 'specificValue'
}
}
- Example:
Control Flow Analysis:
TypeScript's control flow can narrow types within blocks like
if
statements, but these narrowed types may not be recognized outside the block.- Example:
function checkType(value: string | number) {
if (typeof value === 'string') {
// Inside the if block, 'value' is narrowed to string
console.log(value.toUpperCase());
}
// Outside the if block, 'value' is still string | number
}
- Example:
Intersection Types and Narrowing:
Narrowing can behave unexpectedly with intersection types if not all parts of the intersection are valid for all members of the type.
- Example:
type A = { a: number };
type B = { b: string };
function useIntersection(value: A & B) {
if ('a' in value) {
// 'value' is narrowed to A & B, not just A
console.log(value.a, value.b);
}
}
- Example:
Type Predicates in Interfaces and Type Aliases:
While you can use type predicates in interfaces and type aliases, they can lead to issues if not every implementation of the interface or every alias satisfies the predicate.
Example:
interface Bird {
fly: () => void;
}
interface Fish {
swim: () => void;
}
function isBird(pet: Bird | Fish): pet is Bird {
return (pet as Bird).fly !== undefined;
}