In programming, data type names, especially in languages like Java and C#, carry significant weight. Nominal typing, the system employed by these languages, emphasizes the importance of type names. Even if two classes share identical properties, they are considered distinct and incompatible if they have different names and lack shared inheritance or interface. Compatibility requires classes to be related through inheritance or a shared interface, even if they seem identical.
As an example, in Java, if we have a class Person
with properties like name
and age
, and another class User
with the same properties but no direct inheritance or interface, we cannot use a Person
object where a User
is expected. In these languages, type names carry substantial weight.
Unlike nominal typing, TypeScript, a superset of JavaScript, employs “structural typing.” It prioritizes type structure over names—types with matching structures are treated as interchangeable, regardless of their names. This “structural typing” approach enhances dynamic flexibility in working with types, challenging conventional principles.
Note: This structural typing concept also applies to inheritance. In many languages, if we have a class
Child
that inherits fromParent
, we can only use aChild
where we expect aParent
. But in TypeScript, if aChild
has all the properties and structure of aParent
, TypeScript treats them as compatible, even if there's no explicit "extends" relationship between them. This can be a bit surprising if you're used to languages where type names and inheritance hierarchies are strict. But in TypeScript, it offers a more flexible and dynamic way of working with types.
As we have discussed, in TypeScript, the crux of structural typing is the structure or shape of a type, not its name. A type's structure is defined by its properties and their corresponding types. Therefore, if two types share the same structure, TypeScript considers them interchangeable.
Let's illustrate this with an example:
type Person = { name: string; age: number };type User = { name: string; age: number };
Even though these two types bear different names (Person
and User
), TypeScript treats them as the same type because their structures (with properties name
and age
) are identical. This means that a type is compatible with another if it has at least the same properties with matching types. This compatibility extends to variables, function parameters, and return values. For instance, if we have a function that expects a User
, we can still pass a Person
because their structures match.
type Person = { name: string; age: number };type User = { name: string; age: number };function printUser(user: User) {console.log(user.name, user.age);}const person: Person = { name: "John Doe", age: 30 };printUser(person); // This works because Person's structure matches User's
Lines 1–2: Two types are defined: Person
and User
. Each type is described as an object with two properties: name
and age
. Both properties have specific types—name
is of type string
, and age
is of type number
.
Lines 4–6: A function named printUser
is declared. This function takes a single parameter named user
, which is expected to be of type User
. Inside the function, it logs the name
and age
properties of the user
object to the console.
Line 8: A constant variable named person
is defined and assigned an object. This object conforms to the Person
type definition, with name
set to "John Doe"
and age
set to 30
.
Line 9: The printUser
function is called with the person
object as an argument, like this: printUser(person);
. TypeScript checks if the person
object (which is of type Person
) can be used as an argument for the printUser
function, which expects a parameter of type User
. TypeScript determines that the person
object's structure (properties name
and age
) matches the expected structure of a User
. Therefore, TypeScript allows the call to proceed without any type errors and we get the output without any errors.
It's important to distinguish structural typing from nominal typing. In nominal typing systems, the names of types are crucial for determining type compatibility. Let's illustrate the difference between structural typing in TypeScript and nominal typing in Java with an example:
class Person {String name;int age;public Person(String name, int age) {this.name = name;this.age = age;}}class User {String name;int age;public User(String name, int age) {this.name = name;this.age = age;}}class Main {public static void greet(Person person) {System.out.println("Hello, " + person.name + "!");}public static void main(String[] args) {User user = new User("John Doe", 30);greet(user); // This doesn't work in Java, because User and Person are distinct classes.}}
In Java, as demonstrated in the example, we cannot pass a User
object to a method expecting a Person
, even if the structures of the User
and Person
classes are identical. The only way to achieve compatibility in Java would be to create a common interface or superclass for User
and Person
and have them implement or extend it.
In conclusion, TypeScript's structural typing brings flexibility to type compatibility by emphasizing the structure of types rather than their names. This approach allows for more dynamic and adaptable code, making TypeScript a powerful tool for modern web development.
Free Resources