Skip to main content

Type Aliases and Literal Types_TypeScript Notes 10

Free2019-03-10#TypeScript#可辨识联合#narrow this type#TypeScript Type Aliases#TypeScript Literal Types#type assertions and type guards

The origin of algebraic data types in TS

I. Type Aliases

type PersonName = string;
type PhoneNumber = string;
type PhoneBookItem = [PersonName, PhoneNumber];
type PhoneBook = PhoneBookItem[];

let book: PhoneBook = [
  ['Lily', '1234'],
  ['Jean', '1234']
];

The type keyword can create an alias for existing types, thereby enhancing their readability

Interfaces and Type Aliases

Types are somewhat similar in form to interfaces, both support type parameters, and can reference themselves, for example:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

interface ITree<T> { 
  value: T;
  left: ITree<T>;
  right: ITree<T>;
}

But there exist some essential differences:

  • Type aliases don't create new types, while interfaces define a new type

  • Can give aliases to any type, but cannot define interfaces equivalent to any type (such as primitive types)

  • Cannot inherit or implement type aliases (also cannot extend or implement other types), but interfaces can

  • Type aliases can combine multiple types into a named type, while interfaces cannot describe such combinations (intersection, union, etc.)

// Type combination, interfaces cannot express this type
type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}
function findSomeone(people: LinkedList<Person>, name: string) {
  people.name;
  people.next.name;
  people.next.next.name;
  people.next.next.next.name;
}

In application scenarios, the differences between the two are as follows:

  • Interfaces: OOP scenarios (because they can be inherited and implemented, maintaining type hierarchy relationships)

  • Type aliases: Scenarios pursuing readability, scenarios interfaces cannot describe (primitive types, intersection types, union types, etc.)

II. Literal Types

There exist two types of literal types: string literal types and numeric literal types

Strings

String literals also have type meaning, for example:

let x: 'string';
// Error Type '"a"' is not assignable to type '"string"'.
x = 'a';
// Correct
x = 'string';

Can be used to simulate the effect of enums:

type Easing = 'ease-in' | 'ease-out' | 'ease-in-out';
class UIElement {
  animate(dx: number, dy: number, easing: Easing) {
    if (easing === 'ease-in') {}
    else if (easing === 'ease-out') {}
    else {
      // Automatically narrows to "ease-in-out" type
    }
  }
}

// Error Argument of type '"linear"' is not assignable to parameter of type 'Easing'.
new UIElement().animate(0, 0, 'linear');

Different string literals belong to different specific types, therefore, (if necessary) can overload like this:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
function createElement(tagName: string): Element {
  return document.createElement(tagName);
}

Numbers

Numeric literals similarly have type meaning:

// Returns 6 possible dice values
function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
  // ...
}

Looks like just an anonymous numeric enum, seems no reason for existence

Existence Meaning

Actually, the meaning of literal types lies in compile-time ability to "reason" combined with type information, for example:

function foo(x: number) {
  // Error This condition will always return 'true' since the types '1' and '2' have no overlap.
  if (x !== 1 || x !== 2) { }
}

function bar(x: string) {
  // Error This condition will always return 'false' since the types '"1"' and '"2"' have no overlap.
  if (x === '1' && x === '2') { 
    //...
  }
}

This type completeness supplementation allows TypeScript to more finely "understand" (statically analyze) code meaning, thereby discovering some less direct potential problems

Nevertheless, by pairing a type with it's unique inhabitant, singleton types bridge the gap between types and values.

III. Enums and Literal Types

We know there's a special enum called union enum, whose members also have type meaning, for example:

// Union enum
enum E {
  Foo,
  Bar,
}

// Enum's type meaning
function f(x: E) {
  // Error This condition will always return 'true' since the types 'E.Foo' and 'E.Bar' have no overlap.
  if (x !== E.Foo || x !== E.Bar) {
    //...
  }
}

This is very similar to the example in literal types:

function f(x: 'Foo' | 'Bar') {
  // Error This condition will always return 'true' since the types '"Foo"' and '"Bar"' have no overlap.
  if (x !== 'Foo' || x !== 'Bar') {
    //...
  }
}

P.S. For comparison, here uses string literal union type ('Foo' | 'Bar') to simulate enum E, actually enum E is equivalent to numeric literal union type (0 | 1), specifically see II. Numeric Enums

From type perspective, union enums are enums composed of numeric/string literals, therefore their members also have type meaning. The name also expresses this connection: union enum, i.e. numeric/string union

P.S. Enum member types and numeric/string literal types are also called singleton types:

Singleton types, types which have a unique inhabitant.

That is to say, there's only one value under a singleton type, for example string literal type 'Foo' can only take value string 'Foo'

IV. Discriminated Unions

Combining singleton types, union types, type guards and type aliases can establish a pattern, called discriminated unions

P.S. Discriminated unions are also called tagged unions or algebraic data types, i.e. types that can be operated on and logically reasoned

Specifically, discriminated unions generally include 3 parts:

  • Some types with common singleton type properties—common singleton property is the distinguishable feature (or called tag)
  • A type alias pointing to the union composed of these types—i.e. the union
  • Type guards for the common property

Narrow parent type by distinguishing common singleton property types, for example:

// 1. Some types with common singleton property (kind)
interface Square {
    kind: "square";
    size: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}
// 2. Define union type, and give it an alias
type Shape = Square | Circle;
// 3. Specific usage (type guard)
function area(s: Shape) {
  switch (s.kind) {
    // Automatically narrows to Square
    case "square": return s.size * s.size;
    // Automatically narrows to Circle
    case "circle": return Math.PI * s.radius ** 2;
  }
}

Considered as a supplement to instanceof type guard, both used to detect compatibility relationships of complex types, differences as follows:

  • instanceof type guard: Suitable for parent-child types with clear inheritance relationships

  • Discriminated union type guard: Suitable for parent-child types without clear inheritance relationships (cannot detect inheritance relationships through instanceof at runtime)

Completeness Checking

Sometimes may want to completely cover all constituent types of union type, for example:

type Shape = Square | Circle;
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // Potential problem: missed "circle"
  }
}

Can implement this guarantee through never type (one of the few application scenarios of Never type):

function assertNever(x: never) {
  throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // case "circle": return s.radius * s.radius;
    // Error Argument of type 'Circle' is not assignable to parameter of type 'never'.
    default: return assertNever(s);
  }
}

If not completely covered, will go to default branch passing s: Shape to x: never triggering type error (if completely covered, default is unreachable branch, won't trigger never error). Can satisfy completeness coverage requirements, but needs to additionally define an assertNever function

P.S. For more information about Never type, see Basic Types_TypeScript Notes 2

Additionally, there's another not so accurate, but also helpful for checking completeness method: enable --strictNullChecks option, and mark function return value. Use default return undefined to guarantee completeness, for example:

// Error Function lacks ending return statement and return type does not include 'undefined'.
function area(s: Shape): number {
  switch (s.kind) {
    case "square": return s.size * s.size;
  }
}

Essentially is non-empty return value detection, not like assertNever is precise to switch granularity, relatively fragile (having default return values, or having multiple switch will break completeness checking)

Reference Materials

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment