React, Typescript, Tailwind CSS

Typescript magic code snippets

"Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” - Martin Fowler

// Dear programmer:
// When I wrote this code, only god and
// I knew how it worked.
// Now, only god knows it!

Clever code is bad. Don't write clever code. The point of typescript is to enable static analysis so you can catch bugs at transpile time instead of runtime. It results in code that is easier to reason and the magic sauce is to understand so called "clever" code and debunk the "magic" behind it. While its true for beginners and other who hate ts to feel that typescirpt makes the code look more complex or add clutter or mask the purpose with convluted type defs, it can only be solved by writting clean code. If you ever feel

oh I am so clever, this line of code can only be written by a genius

think again and break it down and simplify the code.


Below code snippets are what trumped me at first and over time have embraced them in development projects on a daily basis. Here we go, TypeScript "magic" (debunked) code snippets showcasing various useful techniques and features of the language

Using unknown Type

The unknown type is safer than any and requires type assertions or checks.

function processValue(value: unknown) {
  if (typeof value === 'string') {
    console.log(`String value: ${value}`);
  } else if (typeof value === 'number') {
    console.log(`Number value: ${value}`);
  } else {
    console.log('Unknown type');
  }
}

processValue('Hello'); // Output: String value: Hello
processValue(42);      // Output: Number value: 42
processValue(true);    // Output: Unknown type

Exhaustive Checks with never

The never type in TypeScript represents values that never occur. This means that functions that return never never actually return; they either throw an error or enter an infinite loop. It's commonly used for functions that always throw exceptions and for handling exhaustive checks in discriminated unions.

  • Using never to ensure all cases in a union type are handled.

    type Shape = 
      | { kind: 'circle'; radius: number }
      | { kind: 'square'; side: number };
    
    function area(shape: Shape): number {
      switch (shape.kind) {
        case 'circle':
          return Math.PI * shape.radius ** 2;
        case 'square':
          return shape.side ** 2;
        default:
          const _exhaustiveCheck: never = shape;
          return _exhaustiveCheck;
      }
    }
    
    console.log(area({ kind: 'circle', radius: 10 })); // Output: 314.1592653589793
    console.log(area({ kind: 'square', side: 5 }));    // Output: 25
    
  • Throwing Errors with never

    When a function is meant to throw an error, you can explicitly specify that it returns never. This makes it clear that the function does not return any value.

    function throwError(message: string): never {
      throw new Error(message);
    }
    
// Yet another complete example
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    default:
      return assertNever(shape); // Ensure all cases are handled
  }
}

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

// Example usage
console.log(area({ kind: 'circle', radius: 10 }));       // Output: 314.1592653589793
console.log(area({ kind: 'square', side: 5 }));          // Output: 25
console.log(area({ kind: 'rectangle', width: 4, height: 5 })); // Output: 20

// The following will throw an error at runtime and a compile-time error if uncommented:
// console.log(area({ kind: 'triangle', base: 5, height: 10 })); // Error: Unexpected value

Type Guards

Type guards are functions or constructs that allow TypeScript to infer the type of a variable within a conditional block.

  1. typeof Type Guard:

    • Used to check primitive types (string, number, boolean, symbol, undefined, object, function).
    function isString(value: unknown): value is string {
      return typeof value === 'string';
    }
    
    const val: unknown = "Learndeck.dev";
    if (isString(val)) {
      console.log(val.toUpperCase()); // TypeScript knows val is a string here
    }
    
  2. instanceof Type Guard:

    • Used to check if an object is an instance of a specific class.
    class Person {
      constructor(public name: string) {}
    }
    
    const person: unknown = new Person("Sunny");
    if (person instanceof Person) {
      console.log(person.name); // TypeScript knows person is a Person here
    }
    
  3. Custom Type Guards:

    • In TypeScript, predicate functions are commonly used as type guards. A type guard is a predicate function whose return type is a type predicate (value is Type), indicating to the TypeScript compiler that if the function returns true, then the variable being checked is of the specified type.
    // Example of a predicate function as a type guard
    function isNumber(value: any): value is number {
      return typeof value === 'number';
    }
    
    function processValue(value: any) {
      if (isNumber(value)) {
        // TypeScript now knows 'value' is a number
        console.log(value.toFixed(2)); // No type error here
      } else {
        console.log("Not a number");
      }
    }
    
    // Usage
    processValue(42); // Outputs: 42.00
    processValue("learndeck"); // Outputs: Not a number
    
    // Yet another example
    interface Cat {
      meow(): void;
    }
    
    interface Dog {
      bark(): void;
    }
    
    function isCat(animal: Cat | Dog): animal is Cat {
      return (animal as Cat).meow !== undefined;
    }
    
    const pet: Cat | Dog = { meow: () => console.log("Meow") };
    
    if (isCat(pet)) {
      pet.meow(); // TypeScript knows pet is a Cat here
    }
    
  4. Discriminated Unions:

    • A discriminated union is a union type that also has a property (known as a "discriminant") which is used to narrow down the possible values of the union.
     // Define the Shape interface with a common `kind` property
     interface Shape {
         kind: 'circle' | 'rectangle';
         radius?: number; // Optional radius for Circle
         width?: number; // Optional width for Rectangle
         height?: number; // Optional height for Rectangle
     }
    
     // Define Circle and Rectangle types implementing Shape
     interface Circle extends Shape {
         kind: 'circle';
         radius: number;
     }
    
     interface Rectangle extends Shape {
         kind: 'rectangle';
         width: number;
         height: number;
     }
    
     // Usage example
     function area(shape: Shape): number {
         switch (shape.kind) {
             case 'circle':
                 return Math.PI * shape.radius ** 2;
             case 'rectangle':
                 return shape.width * shape.height;
             default:
                 // TypeScript will throw an error if there are missing cases
                 const _exhaustiveCheck: never = shape;
                 return _exhaustiveCheck;
         }
     }
    
     // Create instances of Circle and Rectangle
     const myCircle: Circle = { kind: 'circle', radius: 5 };
     const myRectangle: Rectangle = { kind: 'rectangle', width: 10, height: 20 };
    
     console.log(area(myCircle)); // Output: 78.54
     console.log(area(myRectangle)); // Output: 200
    
    // Yet another example 
     interface Bird {
       type: 'bird';
       flyingSpeed: number;
     }
    
     interface Horse {
       type: 'horse';
       runningSpeed: number;
     }
    
     type Animal = Bird | Horse;
    
     function moveAnimal(animal: Animal) {
       let speed;
       switch (animal.type) {
         case 'bird':
           speed = animal.flyingSpeed;
           break;
         case 'horse':
           speed = animal.runningSpeed;
           break;
       }
       console.log(`Moving at speed: ${speed}`);
     }
    
     moveAnimal({ type: 'bird', flyingSpeed: 10 });  // Output: Moving at speed: 10
     moveAnimal({ type: 'horse', runningSpeed: 20 }); // Output: Moving at speed: 20
    

Union and Intersection Types

TypeScript allows combining types in a flexible way using unions and intersections.

type Admin = {
  name: string;
  privileges: string[];
};

type User = {
  name: string;
  startDate: Date;
};

type ElevatedUser = Admin & User;

const eUser: ElevatedUser = {
  name: 'Sunny',
  privileges: ['server-access'],
  startDate: new Date()
};

console.log(eUser);

Mapped Types

Mapped types allow you to create new types by transforming properties of an existing type.

type ReadOnly<T> = {
  readonly [P in keyof T]: T[P];
};

type Person = {
  name: string;
  age: number;
};

const john: ReadOnly<Person> = {
  name: 'John',
  age: 25
};

// john.age = 30; // Error: Cannot assign to 'age' because it is a read-only property.

Conditional Types

Conditional types allow creating types that depend on a condition.

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

type Email = {
  message: string;
};

type Dog = {
  bark(): void;
};

type EmailMessageContents = MessageOf<Email>; // string
type DogMessageContents = MessageOf<Dog>;    // never

Utility Types

TypeScript provides several utility types to facilitate common type transformations.

type Person = {
  name: string;
  age: number;
  address?: string;
};

// Make all properties optional
type PartialPerson = Partial<Person>;

// Pick a subset of properties
type NameOnly = Pick<Person, 'name'>;

// Exclude properties
type WithoutAddress = Omit<Person, 'address'>;

const partial: PartialPerson = {};
const nameOnly: NameOnly = { name: 'Sunny' };
const withoutAddress: WithoutAddress = { name: 'Bob', age: 30 };

Extract and Exclude Utility Types

These utility types allow manipulation of union types.

type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>;  // 'a'
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'f'>;  // 'b' | 'c'

console.log('Extract:', T0);
console.log('Exclude:', T1);

Readonly and Record Utility Types

These utility types enhance type safety by providing immutable properties and predefined object shapes.

type Point = {
  x: number;
  y: number;
};

const origin: Readonly<Point> = { x: 0, y: 0 };
// origin.x = 1;  // Error: Cannot assign to 'x' because it is a read-only property.

type Role = 'admin' | 'user';
const roles: Record<Role, string> = {
  admin: 'Administrator',
  user: 'Regular User'
};

console.log(roles);

Utility Types for Function Arguments

Extract function argument types using utility types.

function greet(name: string, age: number): string {
  return `Hello ${name}, you are ${age} years old.`;
}

type GreetArgs = Parameters<typeof greet>;

const args: GreetArgs = ['Sunny', 30];

console.log(greet(...args)); // Output: Hello Sunny, you are 30 years old.

Extracting Function Return Types

Extracting the return type of a function for reuse.

function fetchData() {
  return {
    userId: 1,
    userName: 'John'
  };
}

type FetchDataReturnType = ReturnType<typeof fetchData>;

const data: FetchDataReturnType = {
  userId: 1,
  userName: 'John'
};

console.log(data); // Output: { userId: 1, userName: 'John' }

Type Inference with Generics

Type inference with generics in TypeScript allows the compiler to automatically determine the specific types of generic parameters based on the provided arguments. This feature makes generic functions and classes more flexible and easier to use, as the user doesn't need to manually specify the types.

  • Consider a simple function that returns the value it receives as an argument. Using generics, TypeScript can infer the type of the argument and return value.

    // Identity function
    function identity<T>(arg: T): T {
        return arg;
    }
    
    // TypeScript infers the type of T as number
    const num = identity(123);  // num is of type number
    
    // TypeScript infers the type of T as string
    const str = identity('hello');  // str is of type string
    
  • Inference in Functions with Multiple Generics: When a function uses multiple generics, TypeScript can infer each type separately based on the arguments passed.

    function makePair<T, U>(first: T, second: U): [T, U] {
      return [first, second];
    }
    
    // TypeScript infers T as number and U as string
    const pair = makePair(1, 'one');  // pair is of type [number, string]
    
  • Inference with Generic Classes: Type inference also works with classes that use generics.

    class Box<T> {
      content: T;
    
      constructor(value: T) {
          this.content = value;
      }
    
      getContent(): T {
          return this.content;
      }
    }
    
    // TypeScript infers T as string
    const stringBox = new Box('hello');
    const content = stringBox.getContent();  // content is of type string
    
  • Constraints on Generics: You can constrain generics to specific types using the extends keyword, which restricts the types that can be used.

    interface Lengthwise {
        length: number;
    }
    
    function logLength<T extends Lengthwise>(arg: T): T {
        console.log(arg.length);
        return arg;
    }
    
    // TypeScript infers T as string (string has a length property)
    logLength('hello');  // Output: 5
    
    // TypeScript infers T as an array of numbers (arrays have a length property)
    logLength([1, 2, 3]);  // Output: 3
    
    // This would cause an error as number does not have a length property
    // logLength(123);  // Error: Argument of type 'number' is not assignable to parameter of type 'Lengthwise'
    
  • Inference with Default Generic Types: You can provide default types for generics, which TypeScript will use if no type is specified.

    function createArray<T = string>(length: number, value: T): T[] {
      return Array(length).fill(value);
    }
    
    // TypeScript infers T as number
    const numArray = createArray(3, 42);  // numArray is of type number[]
    
    // TypeScript uses the default type string
    const strArray = createArray(3, 'hi');  // strArray is of type string[]
    
  • Type Inference with Higher-Order Functions: TypeScript can infer generic types in higher-order functions as well.

    function mapArray<T, U>(array: T[], callback: (item: T) => U): U[] {
      return array.map(callback);
    }
    
    // TypeScript infers T as number and U as string
    const lengths = mapArray([1, 2, 3], (num) => num.toString().length);  // lengths is of type number[]
    

Type Assertions

Type assertions provide a way to override TypeScript's inferred types. Type assertions is a way to tell the compiler to treat a value as a specific type. This can be useful when you know more about the type of a value than TypeScript does. Type assertions do not change the runtime behavior of your code but are purely a compile-time feature to assist the TypeScript compiler.

  • There are two syntaxes for type assertions:

    1. Angle Bracket Syntax: value as Type
    2. As Syntax: <Type>value
let someValue: any = 'this is a string';
let strLength: number = (someValue as string).length;

console.log(strLength); // Output: 16
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

console.log(strLength); // Output: 16
  • Caveats
    • Avoid Misuse: Type assertions should not be used to override TypeScript's type system without a good reason. Incorrect type assertions can lead to runtime errors.

    • Type Assertions vs. Type Casting: Type assertions are different from type casting in other languages. Type assertions only affect TypeScript's compile-time type checking and do not alter the actual type of the variable at runtime.

    • Double Assertion: Sometimes you may need to use double assertions to work around TypeScript limitations, but use this sparingly as it can lead to less type-safe code.

      let someValue: any = "this is a string";
      let strLength: number = (someValue as unknown as number).length;
      
      console.log(strLength); // Output: undefined
      

Index Types

Index types allow you to describe types for objects that have unknown keys. Index types are often used in conjunction with mapped types, keyof, and the Record utility type to create and manipulate types that represent object properties dynamically.

  • The keyof operator can be used to obtain the keys of an object type as a union of string literal types.

    interface Person {
        name: string;
        age: number;
    }
    
    type PersonKeys = keyof Person;  // "name" | "age"
    
  • Index types allow you to look up the type of a property given its key. You can use the square bracket notation to access the type of a property.

    interface Person {
        name: string;
        age: number;
    }
    
    type NameType = Person['name'];  // string
    type AgeType = Person['age'];    // number
    
  • Index Signatures: allow you to define types for properties that are not predefined, useful for objects with dynamic keys.

    interface ErrorContainer {
      [prop: string]: string;
    }
    
    const errorBag: ErrorContainer = {
      email: 'Not a valid email!',
      username: 'Must start with a capital character!'
    };
    
    console.log(errorBag);
    
    
    interface StringMap {
        [key: string]: string;
    }
    
    const myMap: StringMap = {
        firstName: 'John',
        lastName: 'Doe'
    };
    
  • Mapped Types allow you to create new types by transforming properties of an existing type.

    interface Person {
        name: string;
        age: number;
    }
    
    // All properties of Person are now optional
    type PartialPerson = {
        [P in keyof Person]?: Person[P];
    };
    
    // All properties of Person are now readonly
    type ReadonlyPerson = {
        readonly [P in keyof Person]: Person[P];
    };
    
  • The Record Utility Type helps create object types with a specified key and value type.

    // Creates an object type with string keys and number values
    type StringNumberMap = Record<string, number>;
    
    const scores: StringNumberMap = {
        alice: 100,
        bob: 95
    };
    
  • You can use index types in functions to enforce constraints on object properties.

    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    const person: Person = { name: 'Alice', age: 30 };
    
    const name = getProperty(person, 'name');  // Type is string
    const age = getProperty(person, 'age');    // Type is number
    
  • Index types are useful for dynamically accessing properties of an object type.

    interface Person {
      name: string;
      age: number;
      location?: string;
    }
    
    function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
        return obj[key];
    }
    
    const person: Person = { name: 'Bob', age: 40, location: 'New York' };
    
    const name = getValue(person, 'name');  // string
    const age = getValue(person, 'age');    // number
    const location = getValue(person, 'location');  // string | undefined
    

Tuple Types

  • Tuples are fixed-length arrays with specified types for each element.

    type Role = [number, string];
    
    const admin: Role = [1, 'Admin'];
    // admin[1] = 10;  // Error: Type '10' is not assignable to type 'string'
    
    console.log(admin);
    
  • Variadic Tuple Types: allow creating tuples with a variable number of elements.

    type Tuple = [string, number, ...boolean[]];
    
    const myTuple: Tuple = ['hello', 42, true, false, true];
    
    console.log(myTuple);
    
  • Tuple to Union Conversion: Convert a tuple type to a union type.

    type TupleToUnion<T extends any[]> = T[number];
    
    type MyTuple = [string, number, boolean];
    type MyUnion = TupleToUnion<MyTuple>; // string | number | boolean
    
    const value: MyUnion = 'Hello'; // Works
    // const anotherValue: MyUnion = {}; // Error: Type '{}' is not assignable to type 'string | number | boolean'
    

Mapped Types with Conditional Modifiers

Mapped types can have conditional modifiers based on property types.

type Optional<T> = {
  [K in keyof T]?: T[K] extends Function ? never : T[K];
};

type Person = {
  name: string;
  age: number;
  greet(): void;
};

type OptionalPerson = Optional<Person>;

const person: OptionalPerson = {
  name: 'Sunny',
  age: 25,
  // greet: () => {} // Error: Type '() => void' is not assignable to type 'never'
};

console.log(person);

Augmenting Global Types

TypeScript allows augmenting global types for better type safety in your environment.

// Declare module to augment global types
declare global {
  interface Array<T> {
    customMethod(): void;
  }
}

Array.prototype.customMethod = function() {
  console.log('Custom method on array');
};

const arr = [1, 2, 3];
arr.customMethod(); // Output: Custom method on array

Using Symbol for Unique Property Keys

Using symbol to create unique and non-colliding property keys.

const uniqueKey = Symbol('uniqueKey');

type MyObject = {
  [uniqueKey]: string;
};

const obj: MyObject = {
  [uniqueKey]: 'Hello World'
};

console.log(obj[uniqueKey]); // Output: Hello World
Previous
Typescript - Tips and Tricks