Awesome Typescript I Never Knew @ Manc.JS

Awesome Typescript I Never Knew @ Manc.JS

Recently, I had the pleasure of attending the Manc.JS Meetup where Chris Dell gave a comprehensive overview of TypeScript. This blog post aims to recapitulate some of the most interesting points discussed.

Defining Types on Variables

TypeScript enhances JavaScript by introducing type annotations. By defining types on variables, you can avoid common errors resulting from unexpected variable values. You declare the type of a variable using a colon after the variable name:

let name: string;
name = 'John'; // Ok
name = 123;    // Error: Type 'number' is not assignable to type 'string'.

Type Inference

TypeScript's type inference feature means that you don't always have to explicitly annotate the variable's type. If you assign a value to the variable when declaring it, TypeScript will automatically infer the type:

let age = 20;   // TypeScript infers that 'age' is a number.
age = 'twenty'; // Error: Type 'string' is not assignable to type 'number'.

Literal Types

A literal type is a type that represents exactly one value. These are often used in conjunction with union types to represent a finite set of possible values. Here's an example:

type WindowStates = "open" | "closed" | "minimized";

let currentWindowState: WindowStates = "open";

currentWindowState = "closed"; // This is fine

currentWindowState = "maximized"; // Error: Type '"maximized"' is not assignable to type 'WindowStates'.

In the above example, WindowStates is a literal type that can have one of three specific string values: "open", "closed", or "minimized". Any attempt to assign a different string value to a WindowStates variable will result in a compile-time error.

Number and boolean literals can also be used:

type Zero = 0;
let zeroValue: Zero = 0;

type TrueFlag = true;
let flag: TrueFlag = true;

In the above, Zero can only ever hold the value 0, and TrueFlag can only ever hold the value true. Attempting to assign any other value to these variables will result in a TypeScript error.

The 'any', 'unknown', and 'never' Types

The any type is a powerful feature in TypeScript, allowing you to opt-out of type-checking. However, it should be used sparingly as it circumvents the type system.

let value: any = 5;
value = "now I am a string";
value = { field: "and now I am an object" };

// No errors are caught here, but assigning a number to a string and 
// an object to a string would cause runtime errors.

The unknown type is similar to any, but safer. It's a type-safe counterpart of any that forces you to perform some form of type-checking before you can use the values.

let unknownValue: unknown = 5;
unknownValue = "now I am a string";
unknownValue = { field: "and now I am an object" };

// TypeScript Error: Object is of type 'unknown'.
// console.log(unknownValue.field); 

if (typeof unknownValue === 'object' && unknownValue !== null) {
  // TypeScript knows that `unknownValue` is an object here, so there's no error.
  console.log(unknownValue.field);
}

The never type is used for values that never occur. For instance, a function that always throws an error never returns a value:

function alwaysThrows(): never {
  throw new Error("I always throw, I never return a value!");
}

// Because `alwaysThrows` never returns, it is a compile-time error to try to
// use its return value.
// TypeScript Error: Unreachable code detected.
// let x = alwaysThrows();

The type Operator and Union Types

The type operator is used to create custom types. Union types allow you to combine multiple types into one using the | operator, useful when a variable can be more than one type:

type StringOrNumber = string | number;

let data: StringOrNumber;
data = 'Hello'; // Ok
data = 42; // Ok

Intersection Types

Intersection types are a way of combining multiple types into one. This allows you to mix together several structures to create a new type. It's a way of composing types and adding together existing types to get a single type that has all the features you need.

In TypeScript, you can use the & operator to create an intersection type. Here's a simple example:

type Employee = {
  id: number;
  name: string;
};

type Manager = {
  employees: Employee[];
};

// The ManagerEmployee type is an intersection of Employee and Manager
type ManagerEmployee = Employee & Manager;

let managerEmployee: ManagerEmployee = {
  id: 1,
  name: 'John Doe',
  employees: [{
    id: 2,
    name: 'Jane Smith'
  }]
};

In this example, ManagerEmployee is an intersection of Employee and Manager. A ManagerEmployee has all the properties of Employee (i.e., id and name), as well as all the properties of Manager (i.e., employees).

Note that intersection types can be a bit tricky when combining types with properties that have the same name but different types. In such cases, TypeScript will only allow you to assign values that are assignable to both types. For example:

type A = {
  foo: number;
};

type B = {
  foo: string;
};

type C = A & B;

let c: C = {
  foo: ???  // There's no value that is both a number and a string!
};

In this case, C is not a usable type, because there's no value that can be both a number and a string. So while intersection types are very useful for composing types, be aware of potential pitfalls when intersecting types with overlapping properties.

The as const Syntax

The as const syntax in TypeScript is used to create a "read-only" version of a literal, where all the properties of an object literal become readonly, arrays become read-only tuples, and string, number, or boolean literals don't get widened to their more general type (string, number, boolean). This can be useful when you want to prevent mutations to a literal value.

Here's an example of using as const:

const car = {
  brand: 'Toyota',
  model: 'Camry',
  year: 2020,
} as const;

car.brand = 'Honda'; // Error: Cannot assign to 'brand' because it is a read-only property.

In the above code, the as const makes all properties of the car object readonly, so any attempts to modify the properties will result in a compile-time error.

Another powerful use case of as const is to define an exact set of string or numeric literals that a variable can be set to. This allows you to leverage TypeScript's type-checking to ensure that variables can only be set to an expected set of values:

const COLORS = ['red', 'green', 'blue'] as const;

type Color = typeof COLORS[number]; // 'red' | 'green' | 'blue'

let myColor: Color;

myColor = 'red'; // Ok
myColor = 'purple'; // Error: Type '"purple"' is not assignable to type 'Color'.

In this example, COLORS is a read-only tuple of string literals, and Color is a type that can only be one of those specific string literals. The as const assertion ensures that TypeScript uses these exact values ('red', 'green', 'blue') for the Color type, rather than just string.

Type Narrowing

Type narrowing refers to refining the type of a variable within a certain scope, based on certain checks or conditions.

An example of type narrowing would be using a typeof check in an if statement:

let value: string | number = Math.random() < 0.5 ? "Hello" : 42;

if (typeof value === "string") {
  // In this block, TypeScript knows `value` is a string.
  console.log(value.toUpperCase()); // No error
} else {
  // In this block, TypeScript knows `value` is a number.
  console.log(value.toFixed(2)); // No error
}

Type Guards

Type guards are expressions that perform a runtime check that guarantees the type in some scope. They are generally used in the form of functions.

function isString(test: any): test is string{
  return typeof test === "string";
}

function example(foo: any){
  if (isString(foo)) {
    console.log(foo.length); // string function
  } 
}

In the isString function, test is string is a type predicate. If this function returns true, TypeScript knows that test must be a string in any scope where this function returned true.

Discriminated Unions

Discriminated Unions (also known as tagged unions or algebraic data types) is a pattern in TypeScript which makes it possible to handle scenarios where an object could be of multiple types. Each type in the union has a common literal type property (the "discriminant") that you can check to see which type of object you have.

Here's an example:

type Circle = {
  kind: "circle";
  radius: number;
};

type Square = {
  kind: "square";
  sideLength: number;
};

type Shape = Circle | Square;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      // TypeScript knows this is a Circle
      return Math.PI * shape.radius ** 2;
    case "square":
      // TypeScript knows this is a Square
      return shape.sideLength ** 2;
  }
}

In this example, Shape is a discriminated union of Circle and Square, discriminated by the kind field. In the getArea function, TypeScript can automatically narrow down the type of the shape parameter inside each case of the switch statement.

Exhaustiveness Checking in Discriminated Unions

You can use a utility function to ensure all cases in a discriminated union are handled, a process called exhaustiveness checking. This is a great way to leverage the type system to prevent runtime errors.

Consider the following Shape discriminated union:

type Circle = {
  kind: "circle";
  radius: number;
};

type Square = {
  kind: "square";
  sideLength: number;
};

type Triangle = {
  kind: "triangle";
  base: number;
  height: number;
};

type Shape = Circle | Square | Triangle;

Good Example (exhaustiveness checking)

Here's an example of a function that correctly uses exhaustiveness checking:

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

In this function, if you forget to handle a shape (e.g., a Triangle), TypeScript will raise a compile-time error on the _exhaustiveCheck line: "Type 'Triangle' is not assignable to type 'never'."

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

Bad Example (missing exhaustiveness checking)

Now let's add in the Triangle shape:

type Triangle = {
  kind: "triangle";
  base: number;
  height: number;
};

type Shape = Circle | Square | Triangle;

Because we do not have the default case which performs the exhaustiveness check, we have:

function getAreaBad(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    // Oops, we forgot to handle Triangle!
  }
  // This won't give a compile-time error even though we forgot to handle Triangle.
}

In this case, TypeScript won't catch the error of forgetting to handle the Triangle type in the switch statement. This is why exhaustiveness checking is so important in discriminated unions; it helps ensure all cases are handled.

Generic Functions

In TypeScript, generics are a way to create reusable components that work over a variety of types rather than a single one. This allows users to consume these components and use their own types.

In this case, we'll create a generic function that works with a more complex object structure.

Let's assume we have a generic Response type which represents a response from a server, which has a status property and a data property, where data can be any type:

type Response<T> = {
  status: 'success' | 'failure';
  data: T;
}

We could then create a function that logs out the data from a successful response. To ensure it can work with any type of data, we'll make it a generic function:

function logSuccessData<T>(response: Response<T>): void {
  if (response.status === 'success') {
    console.log('Data:', response.data);
  } else {
    console.log('No data - request failed');
  }
}

In this example, logSuccessData is a generic function that can work with a Response object with any type of data. Here's how you could use it:

const numberResponse: Response<number> = {
  status: 'success',
  data: 42
};

logSuccessData(numberResponse); // Logs: 'Data: 42'

const stringResponse: Response<string> = {
  status: 'failure',
  data: 'Something went wrong'
};

logSuccessData(stringResponse); // Logs: 'No data - request failed'

In both calls to logSuccessData, TypeScript automatically infers the type for T based on the Response object that is passed in. With numberResponse, T is inferred as number, and with stringResponse, T is inferred as string. This way, the logSuccessData function can be used with a Response containing any type of data.

Opaque Types

The ts-essentials package provides a lot of useful type helpers for TypeScript, and one of them is the Opaque type.

The Opaque type allows you to create opaque types in TypeScript, which are a way to prevent illegal use of values by hiding their internal structure and only allowing operations that are defined for that type.

Here's an example where we define UserID and ProductID as opaque types, to ensure they're not used interchangeably:

type UserID = Opaque<number, "UserID">;
type ProductID = Opaque<number, "ProductID">;

function assignProductToUser(userId: UserID, productId: ProductID) {
  // ...
}

// To create a UserID or ProductID, you have to cast a number to the appropriate type:
let userId: UserID = 1 as UserID;
let productId: ProductID = 101 as ProductID;

assignProductToUser(userId, productId);

In this example, a UserID is just a number, and a ProductID is also just a number - but TypeScript treats them as different types, because they're defined as different opaque types. This means you can't accidentally pass a ProductID to a function that expects a UserID, or vice versa.

This is a powerful feature that can help prevent bugs by enforcing stricter type checking. However, be aware that TypeScript only enforces this at compile time. At runtime, a UserID and a ProductID are both just numbers.

One drawback of the Opaque type from ts-essentials is that you have to cast a number to a UserID or ProductID to create values of these types. This can be a bit verbose, but it's necessary to ensure type safety. If you find this to be a problem, you might want to define helper functions to create UserID and ProductID values more easily.

Zod for Validating Objects

Zod is a library for creating and validating schemas. It's a powerful tool when working with TypeScript, as it can infer the types from the schemas you define, ensuring type safety. It provides a set of functions to define and validate data of any shape, while providing excellent TypeScript integration.

Here is a schema for a simple user object:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

In this example, UserSchema is a Zod schema that describes a user object. It specifies that a user object should have id as a number, name as a string, and email as a string that should be a valid email address.

The User type is then inferred from UserSchema. This gives you a TypeScript type that matches the shape of the schema. With Zod, you don't need to manually keep your TypeScript types and Zod schemas in sync - they're automatically linked!

Now, you can use the parse or safeParse method of UserSchema to validate data:

// Suppose we have some data that should be a user object:
const data = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com',
};

// To validate this data against the schema, use the parse method:
const user = UserSchema.parse(data);

// If the data doesn't match the schema, parse will throw an error:
try {
  const user2 = UserSchema.parse({ ...data, id: 'not a number' });
} catch (err) {
  console.log(err);  // This will log an error because 'id' should be a number
}

// If you don't want to use try/catch, you can use safeParse instead:
const result = UserSchema.safeParse({ ...data, id: 'not a number' });

if (!result.success) {
  console.log(result.error);  // This will log an error because 'id' should be a number
}

This makes Zod a powerful tool for validating incoming data in TypeScript. It lets you ensure that data matches a certain shape, and it provides useful error messages when data doesn't match the schema. Plus, it integrates tightly with TypeScript, so you can get compile-time type safety and auto-completion for your schemas.

The satisfies Keyword

The satisfies keyword is a unique TypeScript feature used within type guards to determine if an object conforms to a specific type. This is an alternative to using the zod package for object validation. Here is an example:

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

type RGB = [red: number, green: number, blue: number];

const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    bleu: [0, 0, 255]
} satisfies Record<Colors, string | RGB>;

const redComponent = palette.red.at(0);
const greenNormalized = palette.green.toUpperCase();

Prisma ORM and TypeScript

Chris demonstrated the powerful interaction between Prisma ORM and TypeScript, showcasing how type inference can help when working with objects returned from a database query.

Here is an example that shows how the Prisma Client uses TypeScript's type inference capabilities to ensure type safety in database operations.

Consider the following schema defined in the Prisma Schema Language:

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
}

We can use Prisma Client to perform database operations on the Post model. After fetching data from the database, the resulting objects will be of the type inferred from the model, allowing us to safely access the properties of these objects:

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  const post = await prisma.post.create({
    data: {
      title: "Type Safety with Prisma",
      content: "Prisma Client provides excellent type safety.",
      published: true,
    },
  });

  console.log(post.id); // Okay
  console.log(post.title); // Okay
  console.log(post.content); // Okay
  console.log(post.published); // Okay
  console.log(post.nonExistentProperty); // TypeScript error: Property 'nonExistentProperty' does not exist on type 'Post'.
}

main()
  .catch((e) => {
    throw e;
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

In the above example, we use prisma.post.create() to create a new post. The resulting post object is of the type Post, as inferred from the Prisma schema. Thanks to TypeScript's static type checking, we can safely access properties of the post object knowing that they exist. If we try to access a non-existent property, TypeScript will raise a compile-time error.

Templated Strings at Compile Time

The session also explored an interesting example of using templated strings at compile time. This could be a game-changer for managing localisation dictionaries.

Let's consider a simple localization dictionary. In TypeScript, we can enforce that only valid keys are used with the dictionary, and that the expected arguments for each template are provided.

Consider this localization dictionary:

type Dictionary = {
  welcome: (username: string) => string,
  goodbye: () => string,
};

const dictionary: Dictionary = {
  welcome: (username: string) => `Hello, ${username}!`,
  goodbye: () => 'Goodbye!',
};

In the example above, our dictionary has two keys, 'welcome' and 'goodbye'. The 'welcome' key corresponds to a function that takes a username and returns a string. The 'goodbye' key corresponds to a function that takes no arguments and returns a string.

We can use this dictionary safely, with TypeScript ensuring we provide the correct arguments:

console.log(dictionary.welcome('Alice'));  // Hello, Alice!
console.log(dictionary.goodbye());  // Goodbye!

// TypeScript will give us an error if we use the dictionary incorrectly:
console.log(dictionary.welcome());  // Error: Expected 1 arguments, but got 0.
console.log(dictionary.goodbye('Alice'));  // Error: Expected 0 arguments, but got 1.

In this way, TypeScript can help us to create safer localization dictionaries, by checking the keys and arguments at compile time. This prevents us from making mistakes like using a non-existent key or providing the wrong arguments to a template string.

Chris' example was a lot better than this - as it used a lot of inferring types to convert {{value}} that mapped to an object with value as the key.

AssemblyScript

Chris also introduced us to WebAssembly using AssemblyScript. For those unfamiliar, AssemblyScript is a variant of TypeScript (a subset, to be more precise), which allows developers to write strictly typed TypeScript code and compile it directly to WebAssembly. WebAssembly, or wasm, offers a high-performance run-time environment within the browser, bringing near-native speed to web applications.

To showcase the power of AssemblyScript, Chris chose to walk us through a fascinating application: rendering the Mandelbrot set. The Mandelbrot set, named after the mathematician Benoit Mandelbrot, is a complex and beautiful fractal that's often used as a benchmark for performance because of its computationally intensive nature.

Chris showed us how we could use AssemblyScript to write the logic for generating the Mandelbrot set and then compile that to WebAssembly. He then contrasted this with a JavaScript implementation, demonstrating the stark differences in performance.

The example highlighted how WebAssembly, when used for computationally heavy tasks, can dramatically improve the performance of web applications. While JavaScript is highly optimized and performant for a wide range of applications, the speed benefits of WebAssembly come into play when dealing with tasks that involve heavy computations, like rendering the Mandelbrot set.

What's particularly enticing about AssemblyScript, as Chris demonstrated, is the familiarity it provides for TypeScript developers. Its syntax and type system are similar to TypeScript's, making it an accessible entry point for JavaScript and TypeScript developers looking to leverage the benefits of WebAssembly without having to learn a more complex language like C or Rust.

Conclusion

In summary, the Manc.JS meetup provided insightful and practical knowledge on TypeScript. Chris Dell's in-depth presentation showcased how TypeScript's robust feature set can significantly enhance code quality, maintainability, and predictability. I learnt a good few things that I had never known about. And I will be looking at the ts-essentials package to see if there are any more useful features to help build more robust code.

Resources

MancJS | Meetup
MancJS is Manchester’s monthly user group for JavaScript programmers and software development enthusiasts. We cover a range of topics, including JavaScript, with a focus on web/mobile development, languages, techniques and tools. We have monthly talks and occasional hands-on sessions.Visit our site
The starting point for learning TypeScript
Find TypeScript starter projects: from Angular to React or Node.js and CLIs.
AssemblyScript
A TypeScript-like language for WebAssembly
GitHub - ts-essentials/ts-essentials: All basic TypeScript types in one place 🤙
All basic TypeScript types in one place 🤙. Contribute to ts-essentials/ts-essentials development by creating an account on GitHub.