🥇 Best: Discriminated union with literal field
You already have species – use it!
typescript
1function handlePet(pet: Pet) {
2 if (pet.species === 'canine') {
3 pet.bark(); // TS knows it's Dog
4 pet.walk();
5 } else {
6 pet.swim(); // TS knows it's Fish
7 pet.walk(); // works because both have walk
8 }
9}
1function handlePet(pet: Pet) {
2 if (pet.species === 'canine') {
3 pet.bark(); // TS knows it's Dog
4 pet.walk();
5 } else {
6 pet.swim(); // TS knows it's Fish
7 pet.walk(); // works because both have walk
8 }
9}
🥈 Using in operator
typescript
1function handlePet(pet: Pet) {
2 if ('bark' in pet) {
3 pet.bark(); // ✅ narrowed to Dog
4 } else {
5 pet.swim(); // narrowed to Fish
6 }
7}
1function handlePet(pet: Pet) {
2 if ('bark' in pet) {
3 pet.bark(); // ✅ narrowed to Dog
4 } else {
5 pet.swim(); // narrowed to Fish
6 }
7}
Works because bark exists only on Dog. But this is less explicit than the discriminated union.
🥉 User-defined type guard
typescript
1function isDog(pet: Pet): pet is Dog {
2 return (pet as Dog).bark !== undefined;
3}
4
5function handlePet(pet: Pet) {
6 if (isDog(pet)) {
7 pet.bark();
8 } else {
9 pet.swim();
10 }
11}
1function isDog(pet: Pet): pet is Dog {
2 return (pet as Dog).bark !== undefined;
3}
4
5function handlePet(pet: Pet) {
6 if (isDog(pet)) {
7 pet.bark();
8 } else {
9 pet.swim();
10 }
11}
What doesn't work
typescript
1// ❌ Type predicate incorrectly assumed
2if (typeof (pet as any).bark === 'function') // loses type safety
3
4// ❌ Checking optional property on both types
5// (but here Fish.bark doesn't exist at all)
1// ❌ Type predicate incorrectly assumed
2if (typeof (pet as any).bark === 'function') // loses type safety
3
4// ❌ Checking optional property on both types
5// (but here Fish.bark doesn't exist at all)
Why discriminated unions are best
- Compile-time safety: you can't forget a case (exhaustiveness checking).
- Self-documenting: the
species field clearly states the type.
- No type assertions or
any needed.
Bonus – exhaustive check with never:
typescript
1function handlePet(pet: Pet) {
2 switch (pet.species) {
3 case 'canine':
4 pet.bark();
5 break;
6 case 'fish':
7 pet.swim();
8 break;
9 default:
10 const _exhaustive: never = pet; // if you add a new type, this errors
11 break;
12 }
13}
1function handlePet(pet: Pet) {
2 switch (pet.species) {
3 case 'canine':
4 pet.bark();
5 break;
6 case 'fish':
7 pet.swim();
8 break;
9 default:
10 const _exhaustive: never = pet; // if you add a new type, this errors
11 break;
12 }
13}
✅ Rule of thumb: Always prefer a discriminated union (literal type field) over checking property existence. It's the idiomatic TypeScript pattern and scales beautifully.