TypeScript Advanced Refresher
Advanced Type Relationships
Shape Over Name
TypeScript decides if types are compatible based on their structure, not their declared names. This is called and it's a core principle. If two objects have the same properties with compatible types, TypeScript considers them interchangeable, even if they were created from different classes or interfaces.
Think of it like this: if it walks like a duck and quacks like a duck, TypeScript treats it as a duck. It doesn't care if you named it a Fowl or a WaterBird.
interface Pet {
name: string;
}
class Dog {
name: string;
breed: string;
constructor(name: string, breed: string) {
this.name = name;
this.breed = breed;
}
}
let myPet: Pet;
myPet = new Dog("Fido", "Golden Retriever"); // This works!
// Dog has the 'name' property required by Pet, so it's a valid substitute.
Because the Dog class has a name property of type string, it satisfies the
Variance Explained
Variance describes how subtyping relationships are preserved in more complex types, especially with function signatures. It's all about ensuring type safety when you pass functions around as arguments or return values.
First, there's covariance. This is the intuitive one. If Poodle is a subtype of Dog, then a function that returns a Poodle (() => Poodle) is a valid substitute for a function that returns a Dog (() => Dog). The relationship is preserved. A Poodle is always a Dog.
class Animal {}
class Cat extends Animal { meow() {} }
let createAnimal: () => Animal;
let createCat: () => Cat;
// It's safe to assign createCat to createAnimal
// because a Cat IS an Animal.
createAnimal = createCat; // OK (Covariance of return types)
Then there's the trickier one: contravariance. This applies to function parameters, and the relationship is inverted.
If you have a function that accepts a Dog, you can only safely substitute it with a function that accepts a more general type, like Animal. Why? A function designed to handle any Animal can certainly handle a Dog. But a function that only knows how to handle Dogs would fail if given a Cat.
So, if Dog is a subtype of Animal, then (animal: Animal) => void is a subtype of (dog: Dog) => void.
let handleAnimal: (a: Animal) => void;
let handleCat: (c: Cat) => void;
// It is NOT safe to assign handleAnimal to handleCat.
// handleAnimal might be called with a non-Cat Animal.
// handleCat = handleAnimal; // Error!
// But it IS safe to do the reverse.
// A function that handles Cats can be replaced by one
// that handles any Animal.
handleAnimal = handleCat; // OK (Contravariance of parameters)
Widening and Narrowing
TypeScript often has to infer a type from a value. When it does, it usually chooses a broader, more general type in a process called type widening. When you initialize a variable with let, the compiler assumes its value might change.
let greeting = "hello"; // Type inferred as 'string'
greeting = "goodbye"; // OK
If you want to preserve the specific literal type, you can use const or a type assertion.
const fixedGreeting = "hello"; // Type is '"hello"'
// fixedGreeting = "goodbye"; // Error! Cannot reassign a const.
Type narrowing is the reverse process. You start with a broad type and use runtime checks to convince the compiler that a value has a more specific type within a certain block of code. This is where type guards come in.
A type guard is simply an expression that performs a runtime check. TypeScript understands these checks and will narrow the type accordingly within the scope of that check.
Common type guards include typeof for primitives, instanceof for classes, and the in operator to check for property existence.
function processInput(input: string | number) {
if (typeof input === "string") {
// Here, input is known to be a 'string'
console.log(input.toUpperCase());
} else {
// Here, input is known to be a 'number'
console.log(input.toFixed(1));
}
}
You can also create your own user-defined type guards. These are functions that return a special type predicate: parameterName is Type. This tells the compiler that if the function returns true, the argument passed to it has the specified narrowed type.
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
// This is a user-defined type guard
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // TypeScript knows pet is a Fish here
} else {
pet.fly(); // And a Bird here
}
}
Understanding these relationships is key to building complex, type-safe applications without fighting the compiler.