Account imageLoginSign UpAccount image
Loading votes....
Save Question

How to properly narrow a union type that shares overlapping properties in TypeScript?

clock icon

asked 3 months ago

Message icon

1

Eye icon

4

I have two interfaces:

1interface Dog {
2 species: 'canine';
3 bark: () => void;
4 walk: () => void;
5}
6
7interface Fish {
8 species: 'fish';
9 swim: () => void;
10 walk: () => void; // shared property
11}
12
13type Pet = Dog | Fish;
1interface Dog {
2 species: 'canine';
3 bark: () => void;
4 walk: () => void;
5}
6
7interface Fish {
8 species: 'fish';
9 swim: () => void;
10 walk: () => void; // shared property
11}
12
13type Pet = Dog | Fish;

I want to write a function that calls bark() if it's a Dog, or swim() if it's a Fish. But TypeScript errors when I check pet.bark because Fish doesn't have bark. How can I safely distinguish them?

Current attempt (fails):

1function handlePet(pet: Pet) {
2 if (pet.bark) { // ❌ Property 'bark' does not exist on type 'Fish'
3 pet.bark();
4 } else {
5 pet.swim();
6 }
7}
1function handlePet(pet: Pet) {
2 if (pet.bark) { // ❌ Property 'bark' does not exist on type 'Fish'
3 pet.bark();
4 } else {
5 pet.swim();
6 }
7}

1 Answer

🥇 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.

1

Write your answer here

Top Questions