Skip to main content

07-05-typescripts-structural-typing-pitfalls

TypeScript's Structural Typing Pitfalls:

Understanding Structural Typing:

Structural typing in TypeScript focuses on the shape that values have. This shape is determined by the properties and types those properties have, not by the name of the type. This means that two types are considered the same if they have the same shape, regardless of how they were created or named.

  • Code Example:

    interface Bird {
    fly(): void;
    }

    class Eagle {
    fly() {
    console.log("Eagle flies");
    }
    }

    // Even though 'Eagle' doesn't explicitly say it implements 'Bird', it's still a 'Bird' by structure.
    let bird: Bird = new Eagle();

Excessive Property Checking:

TypeScript allows an object to have more properties than the type specifies as long as it has at least the ones required. However, this can be misleading because you might expect an object to only have the properties defined in its type.

  • Code Example:

    interface Person {
    name: string;
    age?: number; // Optional property
    }

    let employee: Person = {
    name: "Alice",
    age: 30,
    department: "HR" // Not part of 'Person', but TypeScript doesn't complain
    };

Interface and Type Aliases Differences:

Interfaces are flexible and can be extended or implemented multiple times, while type aliases define a type once and cannot be changed or extended later. This difference is important to understand because it affects how types can evolve in a codebase.

  • Code Example:

    interface Animal {
    name: string;
    }

    // Extending an interface
    interface Bear extends Animal {
    honey: boolean;
    }

    // Type aliases cannot be changed after they're created
    type WindowStates = "open" | "closed" | "minimized";
    // Cannot add a new state without creating a new type alias

Implicit Type Inference:

TypeScript can guess the type of a variable if you don't specify it. This is usually helpful but can lead to mistakes if TypeScript guesses wrong, so always check the types it infers.

  • Code Example:

    // TypeScript infers 'score' to be of type 'number'
    let score = 10;

    // TypeScript infers the return type to be 'number[]'
    function getScores() {
    return [score];
    }

Function Compatibility:

For functions, TypeScript looks at the parameters and the return type to determine if they are compatible. Two functions with the same parameters and return type are considered the same, even if their implementations are different.

  • Code Example:

    type StringFunction = (a: string) => void;

    function log(message: string) {
    console.log(message);
    }

    let stringFunction: StringFunction = log;
    // 'log' can be assigned to 'StringFunction' because the parameters and return type match.

Generics Pitfalls:

Generics give you a way to use types as variables in other types, but if you don't specify what the generic type is, TypeScript will use {} by default, which can cause confusion.

  • Code Example:

    function identity<T>(arg: T): T {
    return arg;
    }

    // TypeScript defaults T to '{}' when no argument is provided
    let myIdentity: <T>() => T = identity;

Subtype vs. Assignment:

Subtyping is about one type being a subtype of another and can be replaced wherever the other type is used. Assignment is about whether one type can be assigned to a type. Sometimes, a type can be assigned to another without being a subtype, due to the structural typing system.

  • Code Example:

    interface Animal {
    name: string;
    }

    interface Bear extends Animal {
    honey: boolean;
    }

    let smallAnimal: Animal = { name: "Teddy" };
    let bear: Bear = { name: "Winnie", honey: true };

    // 'Bear' is a subtype of 'Animal', and 'bear' can be assigned to 'smallAnimal'
    smallAnimal = bear;

Union Types and Intersection Types:

Union and intersection types let you create complex type combinations. Union types are used when a value can be one of several types (like string | number). Intersection types are used when a value must be all of the types (like Person & Serializable).

  • Code Example:

    // Union type: A variable can be a string or a number
    let stringValueOrNumber: string | number;

    // Intersection type: A variable must be both a string and a Serializable
    interface Serializable {
    serialize(): string;
    }
    let serializableString: string & Serializable;

Refinement Issues:

Sometimes TypeScript can't narrow down the exact type within a block of code, which can cause unexpected behavior. This is often the case with complex conditions and type guards.

  • Code Example:
    function padLeft(value: string, padding: string | number) {
    if (typeof padding === "number") {
    // TypeScript knows 'padding' is a number here
    return Array(padding + 1).join(" ") + value;
    }
    // TypeScript knows 'padding' is a string here
    return padding + value;
    }

any Type Dangers:

Using any turns off TypeScript's type checking for that variable. If you use any too much, you lose the benefits of TypeScript's type system.

  • Code Example:
    // The 'any' type can be assigned to any value, bypassing type checking
    let notSure: any = 4;
    notSure = "maybe a string instead";

Assignability of Enums:

Enums in TypeScript are compatible with numbers, and you can assign numbers to enums and vice versa. But this flexibility can be confusing and lead to errors.

  • Code Example:

    enum Status { New, InProgress, Done }

    let status: Status = Status.New;
    status = 2; // This is valid since 'status' can hold a number

Ambient Declarations Pitfalls:

Ambient declarations with the declare keyword are used to describe the shape of code written elsewhere. If they're not accurate, they can cause problems because TypeScript thinks the code exists in one way, but it might be different in reality.

  • Code Example:
    // Ambient declaration for a non-existent function
    declare function nonExistentFunction(args: string): void;

Excess Property Checks in Literals:

When assigning an object to a type, TypeScript checks for excess properties and will throw an error if the object has properties that the type doesn't expect.

  • Code Example:

    type Box = {
    height: number;
    width: number;
    };

    // Error: Object literal may only specify known properties
    let box: Box = { height: 10, width: 20, depth: 30 };

Index Signatures:

An index signature declares the types we expect when we access an object. For example, { [key: string]: any } means any property name with a string key will be acceptable. However, this can lead to unsafe assumptions about the structure of our objects.

  • Code Example:

    // Index signature
    const dictionary: { [key: string]: any } = {};
    dictionary["myProperty"] = "myValue";

    // This is allowed by the index signature, but 'myOtherProperty' might not actually exist
    console.log(dictionary["myOtherProperty"]);
  • Index Signatures:

    Types with index signatures, like { [key: string]: any }, can be a source of type errors because they can accept values with any properties, potentially leading to the use of undefined properties.

  • Type Assertions:

    Type assertions (as Type) can subvert the type checker and should be used sparingly. An incorrect assertion can lead to uncaught runtime errors.

  • Consistent Use of Aliases:

    When aliasing primitive types (type MyString = string), be consistent in their use to avoid confusion and ensure that the type's structural nature is maintained.