Skip to content

Static Typing

In the dynamic world of JavaScript, variables are like chameleons, effortlessly changing their types as the code flows. While this flexibility is one of JavaScript’s strengths, it can also be a source of bugs, especially in larger or more complex applications. Enter TypeScript and its static typing system.

At its core, static typing means defining the type of a variable, parameter, or return value at compile-time, rather than waiting until runtime. This means that even before your code runs, TypeScript can alert you to type mismatches, reducing the potential for runtime errors.

  • Error Reduction: Many common errors in JavaScript arise from type-related issues. By catching these at compile-time, TypeScript drastically reduces the chances of these errors making it to production.

  • Better Readability: When you revisit your code or collaborate with others, having type annotations makes it immediately clear what kind of values are expected.

  • Enhanced Tooling: Static types empower development environments with features like better autocompletion, refactoring, and navigation.

  • Optimized Performance: While TypeScript’s primary goal isn’t performance optimization, in some scenarios, knowing variable types can help JavaScript engines optimize code execution.

Embracing the Best of Both Worlds

While TypeScript champions static typing, it doesn’t enforce it dogmatically. With types like any and unknown, TypeScript provides escape hatches, allowing developers to gradually adopt typing or handle dynamically typed scenarios.

Type System

TypeScript’s type system offers a rich set of tools to provide static typing for JavaScript. Let’s explore some of its core features with illustrative examples.

Structural vs. Nominal Typing

In nominal typing systems, two objects are of the same type if they’re explicitly declared to be. TypeScript uses structural typing, meaning it determines type equivalence based on the structure of types.

interface Dog {
    bark(): void;
}

interface Alarm {
    bark(): void;
}

let dog: Dog = {
    bark: () => console.log("Woof!")
};

let alarm: Alarm = dog; // This is valid in TypeScript due to structural typing.

Basic Types

TypeScript introduces a set of basic types:

Primitive Types

let isDone: boolean = false;
let age: number = 42;
let name: string = "Alice";
truthy and falsy values

TypeScript, like JavaScript, has the concept of truthy and falsy values. In conditions and comparisons, certain values are evaluated as true (truthy) or false (falsy).

Here are the falsy values in TypeScript/JavaScript:

  1. false - The boolean value false itself.
  2. 0 and -0 - The number zero (and its negative counterpart).
  3. "" (empty string) - A string with no characters.
  4. null - Represents the intentional absence of any value or object.
  5. undefined - Indicates a variable has been declared but has not yet been assigned a value.
  6. NaN - Stands for “Not-a-Number” and indicates a value is not a legal number.

Everything else is considered truthy. Some examples of truthy values include:

  1. Any number other than 0 or -0 (e.g., 1, -1, Infinity, -Infinity).
  2. Strings with characters (e.g., "hello", "0", "false").
  3. Arrays, even empty ones (e.g., []).
  4. Objects, even empty ones (e.g., {}).
  5. Functions.

Falsy Values:

if (false) {
    console.log("This won't be printed."); // false is falsy
}

if (0) {
    console.log("This won't be printed."); // 0 is falsy
}

if ("") {
    console.log("This won't be printed."); // empty string is falsy
}

if (null) {
    console.log("This won't be printed."); // null is falsy
}

if (undefined) {
    console.log("This won't be printed."); // undefined is falsy
}

if (NaN) {
    console.log("This won't be printed."); // NaN is falsy
}

Truthy Values:

if (true) {
    console.log("This will be printed."); // true is truthy
}

if (1) {
    console.log("This will be printed."); // 1 is truthy
}

if (-1) {
    console.log("This will be printed."); // -1 is truthy
}

if ("hello") {
    console.log("This will be printed."); // non-empty string is truthy
}

if ("0") {
    console.log("This will be printed."); // "0" as a string is truthy
}

if ("false") {
    console.log("This will be printed."); // "false" as a string is truthy
}

if ([]) {
    console.log("This will be printed."); // an empty array is truthy
}

if ({}) {
    console.log("This will be printed."); // an empty object is truthy
}

if (function() {}) {
    console.log("This will be printed."); // a function is truthy
}

It’s important to understand these concepts, especially when working with conditional statements or logical operations. In TypeScript, the type system can help catch potential issues related to truthy and falsy values, but the underlying behavior is inherited from JavaScript.

Object Types

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

const user: Person = { 
    firstName: "John", 
    lastName: "Doe" 
};

Special Types

let notSure: any = 4; // can be reassigned to any type
let mustCheck: unknown = 4; // must be type-checked before use

In TypeScript, both any and unknown are top types, meaning any type is assignable to them. However, they serve different purposes and have distinct behaviors:

any
  • Represents any possible value.
  • No type-checking is performed when you access properties of an any type, call it as a function, or construct it as a class.
  • It essentially opts out of type-checking for that variable.

Example

let anything: any = "hello";
anything = 42; // No error
anything.someMethod(); // No error, even if someMethod doesn't exist
unknown
  • Represents a value that could be anything, but its type is unknown yet.
  • Unlike any, you can’t do anything with an unknown value until you’ve checked its type.
  • It’s a safer alternative to any when you want to describe a value that comes from a dynamic source.

Example

let uncertainValue: unknown = "hello";
uncertainValue = 42; // No error
// uncertainValue.someMethod(); // Error: Object is of type 'unknown'.

In essence, while any gives complete freedom (and responsibility) to the developer, unknown ensures that type-checking is still enforced until the type is made certain through checks.

Union Types

Union types allow you to define a type that could be one of several types:

let value: number | string;
value = 42;    // valid
value = "Hi";  // valid

Intersection types

Intersection types combine multiple types into one:

interface Name {
    name: string;
}

interface Age {
    age: number;
}

let person: Name & Age = { name: "Alice", age: 30 };

Literal Types

Literal types allow you to restrict a value to specific cases:

let exactString: "hello" = "hello";
// exactString = "world";  // Error: Type '"world"' is not assignable to type '"hello"'.

Type Guards and Narrowing

TypeScript allows you to check the type of a variable at runtime:

if (typeof value === "string") {
    console.log(value.toUpperCase()); // Type of value is narrowed to 'string' in this block
}

Generics

Generics allow components to work with various data types while maintaining type safety:

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

const output = identity<string>("Hello");
const numberOutput = identity(42); // Type inferred as number

Generics are also very useful with collections:

function processArray<T>(items: T[]): T[] {
    // Process items and return the same type array
    return items.map(item => item); // Simple example
}

// Using the function with numbers
const numbers: number[] = [1, 2, 3, 4, 5];
const processedNumbers = processArray(numbers);
console.log(processedNumbers); // [1, 2, 3, 4, 5]

// Using the function with strings
const strings: string[] = ["a", "b", "c"];
const processedStrings = processArray(strings);
console.log(processedStrings); // ["a", "b", "c"]

Inference

TypeScript can often infer types without explicit annotations:

let x = 3;  // TypeScript infers x to be of type 'number'

Type Aliases

In the vast landscape of TypeScript’s type system, Type Aliases stand out as a powerful tool to create custom types. They allow developers to give a new name to a type, be it primitive, union, tuple, or any other type. This not only enhances code readability but also provides flexibility in structuring complex type relationships.

Why Use Type Aliases?

  1. Readability: By giving meaningful names to complex types, you make the code more self-documenting.
  2. Flexibility: Instead of repeating complex type structures, you can define them once and use them wherever needed.
  3. Structuring Complex Relationships: For intricate applications, type aliases can represent relationships, states, or other domain-specific concepts.

Defining a Type Alias

To define a type alias, use the type keyword followed by the alias name:

type StringOrNumber = string | number;

Examples of Type Aliases

  • Union Type Alias:

    type WindowStates = 'open' | 'closed' | 'minimized';
    let currentWindowState: WindowStates = 'open';
    

  • Function Type Alias:

    type Callback = (data: string) => void;
    function fetchData(callback: Callback) {
      // fetch and return data to callback
    }
    

  • Object Type Alias:

    type User = {
      id: number;
      name: string;
    };
    let user: User = { id: 1, name: 'Alice' };
    

Type Aliases vs. Interfaces

While type aliases and interfaces can often be used interchangeably, they have distinct use cases:

  • Extensibility: Interfaces are more extensible as they can be merged using declaration merging.
  • Complex Types: Type aliases are more suited for union types, tuple types, and other complex type structures.

In essence, type aliases provide a way to name and reuse types, making the codebase more maintainable and understandable. Whether you’re dealing with intricate type relationships or just aiming for cleaner code, type aliases are a valuable addition to your TypeScript toolkit.

Advanced Topics

The following sections cover more advanced TypeScript features that are useful for complex scenarios but may not be needed in everyday development.

Template Literal Types

Template literal types allow you to create types based on string templates, enabling powerful string manipulation at the type level:

type World = "world";
type Greeting = `hello ${World}`; // "hello world"

// Practical example
type EventName<T extends string> = `on${Capitalize<T>}`;
type ButtonEvents = EventName<"click" | "hover">; // "onClick" | "onHover"

In more advanced scenarios, conditional and mapped types can be combined with template literal types to create dynamic and flexible type definitions.

Utility Types

TypeScript provides several built-in utility types for common type transformations:

Partial and Required

interface User {
  id: number;
  name: string;
  email: string;
}

// Makes all properties optional
type PartialUser = Partial<User>; // { id?: number; name?: string; email?: string; }

// Makes all properties required (opposite of Partial)
type RequiredUser = Required<PartialUser>; // { id: number; name: string; email: string; }

Pick and Omit

// Pick specific properties
type UserPreview = Pick<User, "id" | "name">; // { id: number; name: string; }

// Omit specific properties  
type UserWithoutId = Omit<User, "id">; // { name: string; email: string; }

Record and Exclude

// Create object type with specific keys and value types
type UserRoles = Record<"admin" | "user" | "guest", boolean>;
// { admin: boolean; user: boolean; guest: boolean; }

// Exclude types from union
type PrimaryColors = "red" | "green" | "blue" | "yellow";
type WarmColors = Exclude<PrimaryColors, "blue">; // "red" | "green" | "yellow"

ReturnType and Parameters

function createUser(name: string, age: number): User {
  return { id: 1, name, email: `${name}@example.com` };
}

type CreateUserReturn = ReturnType<typeof createUser>; // User
type CreateUserParams = Parameters<typeof createUser>; // [string, number]

Const Assertions

Const assertions tell TypeScript to infer the most specific type possible:

// Without const assertion
let colors = ["red", "green", "blue"]; // string[]

// With const assertion
let specificColors = ["red", "green", "blue"] as const; // readonly ["red", "green", "blue"]

// Object const assertion
const theme = {
  colors: ["red", "blue"],
  sizes: ["small", "large"]
} as const;
// Type: { readonly colors: readonly ["red", "blue"]; readonly sizes: readonly ["small", "large"]; }

The satisfies Operator

The satisfies operator ensures a value satisfies a type without widening the inferred type:

type Colors = "red" | "green" | "blue";

// Using satisfies to ensure type safety while preserving literal types
const favoriteColors = {
  primary: "red",
  secondary: "blue"
} satisfies Record<string, Colors>;

// favoriteColors.primary is inferred as "red", not string
// but TypeScript ensures all values are valid Colors

// Practical example with mixed types
type Config = {
  apiUrl: string;
  retries: number;
  features: string[];
};

const config = {
  apiUrl: "https://api.example.com",
  retries: 3,
  features: ["auth", "cache"]
} satisfies Config;

// config.apiUrl is inferred as the literal "https://api.example.com"
// while still ensuring the object structure matches Config