TypeScript 3 fundamentals

In notebook:
Work Notes
Created at:
2019-09-26
Updated:
2019-10-02
Tags:
JavaScript Fundamentals

These are my notes taken on the online workshop by FrontendMasters.

Workshop instructor Mike North, who runs the developer training program and tech lead for TypeScript adoption at LinkedIn.

TypeScript intro

An open-sourced, typed, superset of JavaScript. It compiles (and runs as) standard JavaScript. This means that the type checking does not happen when the code runs in the browser, only at code authoring and compilation time.

There are three parts to the "ecosystem", the Language, the Language Server (giving information to a code editor) and the Compiler.

Note, that while Babel 7 can also compile TypeScript, it does not do type checking.

Why add type checking?

The idea is to that you make the constraints and assumptions about your application visible in your code. This helps reduce cognitive load, by serving as an extra documentation on the program (visible in a compatible code editor). It also helps catch code errors at compile time instead of at runtime.

TypeScript compilation

npm install -g typescript

Then to you can start compiling your *.ts files by:

tsc src/index.js --target ES2017 --module commonjs --watch

The extra flags are of course optional, and self-explanatory.

Config file

A more complete tsconfig.json file would be:

  {
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2017",
    "outDir": "lib",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["examples/*/src", "challenges/*/src", "notes"]
}

The declaration option

It creates an extra index.d.ts file that only contains the type declaration. It will be picked by the code editor (language server).

A more complete tsconfig setup

  {
  "compilerOptions": {
  "jsx": "react",
  "strict": true,
  "sourceMap": true,
  "noImplicitAny": true,
  "strictNullChecks": true,
  "allowJs": true,
  "types": [],
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true,
  "moduleResolution": "node",
  "target": "es2015"
  }
}

TypeScript understandns JSX, so you can name your files .tsx and it will know how to compile them.

The target option

You can also set it to esnext and let Babel to the transpiling to your target environment.

Most of the other compiler options are related to strictness settings. These will be explained later in the article. Depending on how you start your code (convert from standard JavaScript or start from zero), you would enable these strictness settings progressively through several steps (and code commits).

Setting types

Letting the compiler infer the types

The easiest way is when the TypeScript engine can figure out by itself the type:

let foo = "bar";

The compiler will assign the type string to foo and will not let you assign a number type to it.

const foo = "bar"

Again, the compiler can infer the type you are using, which is a JavaScript primitive type (e.g. string or number) and that it's a const declaration and will warn you when you try to reassign a different value to it.

If foo was an object:

const foo = {
  bar: "baz"
  }

The TypeScript compiler will understand that it's a const declaration will try to guard you from changing the shape of the object (not allowed with const declaration), but will let you reassign the value to foo.bar (allowed with const declaration).

Difference between variable declaration and initialisation

In the following example, we first declare a variable, and only initialise it with a new value later:

let foo;
foo = 42;
foo = "bar"; // the compiler doesn't show an error

In this case, the compiler will set the type of foo to any. It's a top type, meaning very wide ie. can hold many different types.

Type annotation

In the above case, where you declare a variable but don't initialise it, you can declare it's intended type:

let foo: number;
foo = 41 // no error
foo = "bar" // error

Mike recommends to add type annotations for variable declarations, as a means of code documentation and when you want to be explicit about the "contract".

Annotating arrays

See the following example. Without a type annotation, TypeScript will infer it as a never type. It's so called bottom type, meaning no other type can go into it.

let foo = []; // we just initialised an array of nevers
foo.push(42) // compiler error, a number type cannot go into a never type

You can either set the type of the members of the array:

let foo: number[] = [];
foo.push(42); // works
foo.push("bar") // compiler error

To corretly declare an array that can hold any item (see above example with never):

let foo: any[] = [];
Tuples

Tuples are fixed length arrays. You can define a type for each slot. If you do, the array will automatically a tuple, meaning it can not hold more than the predefined number of items.

let foo: [number, string, any, string, number] = [];

Note, that the compiler cannot check the Array.push() calls (foo.push("bar")). In practice, you would never build up an array incrementally so this is not really an issue.

Annotating objects

To decrlate the "shape" of an object:

let foo: { bar: number; baz: string };
foo = {
  bar: 42,
  baz: "xyz"
}

Now, all properties (bar and baz) become mandatory and the compiler will throw an error if you try to declare foo without any of them.

To make them optional:

let foo: { bar: number; baz?: string }

Interface

You can define reusable type annotations that can be applied to JavaScript "object types" (Object, Function, Array).

interface HasPhoneNumber {
  name: string;
  phone: number;
}
interface HasEmail {
  name: string;
  email: string;
}

// then use one
let email: HasEmail {
  name: "foo name",
  email: "foo email"
}

Combining interfaces with Intersection or Union

Interfaces in TypeScript have many uses and also have some advanced behaviours (e.g. hoisting, see below).

Intersection types
let contactInfo: HasPhoneNumber | HasEmail;

Now contactInfo must have a .name property and may have an .email or .phone property:

contactinfo = {
  name: "foo",
  phone: 42
} // no error

contactinfo = {
  name: "bar",
  email: "baz"
}

Important to note, that with the Intersection contract we can only access the .name property, but not .email or .phone (e.g. when you start typing in your editor contactinfo.|, only name is shown.

Union type
let fullContactInfo: HasPhoneNumber & HasEmail;

Now, when you define a variable based on the fullContactInfo type, it must have all the properties of the two interfaces. Also when you use a variable based on this type, you have access to all their properties: contactinfo.| will list name, phone and email.


Nominal vs Structural type Systems

Let's consider this code snippet:

function validateInput(input: HTMLInputElement) {...}
validateInput(x)

There are two ways the compiler can check for type equivalence for the paramater x. In a Nominal type systems, it would check based on instances or classes. Is x an instance of HTMLInputElement? This could only be possible in JavaScript if we wrote everything in a very OO style and use constructors for everything.

In Structural type systems, the engine (compiler) checks the shape of x. Does it have the expected property names and their values are of the correct types?



The concept of wider and narrower types

A "wider" type is one that is less restrictive, a "narrower" is one that is more specific.

An example of type definitions going from wider to narrower:

  1. any -> anything can go here
  2. any[] -> narrower, must be an array of anys
  3. sting[] -> array of strings
  4. [string, string, string] -> 3 items of strings
  5. ["foo", "bar", "baz"] -> must be these things
  6. never -> the bottom type, nothing can go here

Annotating functions

function sendMail(to: HasEmail): { recipient: string; body: string } {
  return {
    recipient: `${to.name} <${to.email}>`,
    body: "foo..."
  }
}

Explanation:

(to: HasEmail): means the argument to has to be the type of the interface HasEmail (name and email props)

..): { recipient: string; body: string } { ... }: defines the return type of the function

Fat arrow syntax

const sendMail(to: HasEmail): { recipient: string; body: string } => {
  return ...
}

Mike recommends not overconstraining your argument types. It can be useful in different places and so should be able to accept different inputs.

On inferring return types

TypeScript is pretty good at inferring the return types of functions. If there are lot of branching inside the function and may return undefined properties, via for example an "early return" (e.g. if(x < 2) return;) then in this case you may want to start specifying the return type.

The void return type

function foo(bar: number[]): void {
  bar.forEach(...)
}

(My note) it's for functions that do side-effects and don't return a meaningful value.

Rest params

function sum(...vals: number[]) {
 return vals.reduce(...)
}

Rest params work, but they must be an array type.

Function signature overloading

Mike presented a more complex use case, where a function receives two arguments. The two arguments are related, the value of the first argument has an effect on the acceptable type of the second argument.

function contactPeople(
  method: "email" | "phone",
  ...people: (HasEmail | HasPhoneNumber)[]
): void {
  // the implementation...
  ...
}

This definition would let you use it like this:

contactPeople('email', {name: "bar", phone: 42})

Where the two arguments don't match up.

Instead you would need to use signature overloading. You can add extra function type annotations that constrain further the base case:

function contactPeople(method: "email", ...people: HasEmail[]): void;
function contactPeople(method: "phone", ...people: HasPhoneNumber[]): void;

// then do the base case
function contactPeople(
  method: "email" | "phone",
  // or just
  method: string,
  ...people: (HasEmail | HasPhoneNumber)[]
): void {
  // the implementation...
  ...
}

Make sure to have the "base case" as well, not only the overloading. This is for the inside of the function (the // implementation) part to have the correct type checking.

Lexical scope

You can set up TypeScript to check for the lexical scope (the type of the this variable) based on the execution context.

function sendMessage(
  this: HasEmail & HasPhoneNumber,
  preferredMethod: "phone" | "email") {
  // implementation that uses `this` (e.g. ... sendEmail(this))
  ...
  }

TypeScript will now try to check for the this variable:

  sendMessage.call(null, "foo") // will show an error
sendMessage.call({name:"foo", phone:123, email: "bar"}) // will pass

You need at least TypeScript version 3.5 or 3.6 for the strict type checking for call and apply.

Type Aliases

You can name (assign to variables) your type declarations (similar to interfaces). You can assign to a type alias anything that you would do in a type declaration. In this sense, they are more flexible than interfaces.

type StringOrNumber = string | number;
// or
type HasName = { name: string };

Compared to interfaces

Interfaces can only be assigned to derivatives of the JavaScript Object type, but type aliases also work with primitive types.

Interfaces are similar to functions in that they can hoisted. Type aliases are read line by line by the compiler, and so hoisting will not work with type aliases, and so self-referential types won't work either:

type NumVal = 1 | 2 | 3 | NumArr;
type NumArr = NumVal[]; // this will NOT work

Interfaces

Interfaces can extend other interfaces (as you can extend JavaScript Objects):

interface InternationalPhone extends HasPhoneNumber {
  countryCode: string;
};

Call signatures

Type aliases and Interfaces can be used to describe call signatures:

interface ContactMessenger {
  (contact: HasEmail | HasPhoneNumber, message: string): void;
}

or with a type alias:

type ContactMessenger = (
  contact: HasEmail | HasPhoneNumber,
  message: string
) => void

Once you created a call signature, you can reuse it in your function declarations and don't need to declare the types there (for _contact and _message):

function emailer: ContactMesssenger (_contact, _message) {
  ...  
}

This will also work for callbacks.

Construct signatures

For functions that you intend to be called with the new keyword.

interface ContactConstructor {
  new (...args: any[]): HasEmail | HasPhoneNumber;
}

This describes that your function called with any arguments, should return an object that has the "shape" of HasEmail or HasPhoneNumber.

Dictionaries

Dictionary is a key-value data structure. Usually the key is the lookup mechanism, e.g. an id or an index.

interface PhoneNumberDict {
  [numberName: string]: undefined | {
    areaCode: number;
    num: number;
  }
}

We need the undefined "case" for when we attempt to look up a key that doesn't exist.

Then to use it:

const phoneDict: PhoneNumberDict = {
  office: { areaCode: 123, num: 456 },
  home: { areaCode: 789, num: 321 }
}

Combining dictionary interfaces

interfaces are "open", meaning any declarations of the same name are merged. You can extend an existing dictionary interface to make it more specific. You can declare the dictionary as above, or it can be imported from a library, then re-declare it with the same name to add more specific members to it.

interface PhoneNumberDict {
  home: {
    areaCode: number;
    num: number;
  };
  office: {
    areaCode: number;
    num: number;
  };
}

Now, the home and office members are mandatory, but you can add other members freely. Again, you can take advantage of the "hoisting" of interfaces, and this extra declaration can come before or after the "base" declaration.

More on interfaces vs types

Types are sorted and evaluated eagerly, while interfaces lazily – only when we start to access them the compiler starts to work out the allowable types for it.

Testing TypeScript

Mike recommends the dtslint utility for testing your type declarations.

An example test file would have these lines. The comments are the assertions.

  isJSONValue([]);
isJSONValue(4);
isJSONValue("abc");
isJSONValue(false);
isJSONValue({ hello: ["world"] });
isJSONValue(() => 3); // $ExpectError

isJSONArray([]);
isJSONArray(["abc", 4]);
isJSONArray(4); // $ExpectError
isJSONArray("abc"); // $ExpectError
isJSONArray(false); // $ExpectError
isJSONArray({ hello: ["world"] }); // $ExpectError
isJSONArray(() => 3); // $ExpectError

isJSONObject([]); // $ExpectError
isJSONObject(["abc", 4]); // $ExpectError
isJSONObject(4); // $ExpectError
isJSONObject("abc"); // $ExpectError
isJSONObject(false); // $ExpectError
isJSONObject({ hello: ["world"] });
isJSONObject({ 3: ["hello, world"] });
isJSONObject(() => 3); // $ExpectError

Classes in TypeScript

There are two extensions to the JavaScript class syntax, the implements and access modifier keywords.

TypeScript implements

implements means a class aligning with a particular interface:

  class Contact implements HasEmail {
  email: string; // need to declare here the fields that WILL exist
  name: string;
  constructor(name: string, email: string) { // then pass it to the constructor
    this.email = email;
    this.name = name;
  }
}

Simplifying the syntax and access modifier keywords

The above syntax can be further simplified:

  class Contact implements HasEmail {
  constructor(
    public name: string,
    public email: strong = "no email"
    ) {
      // nothing needed
    }
}

The access modifier keywords are:

  • public - everyone
  • protected - the class and it's subclasses
  • private - only the instance
  • readonly

Note, that these are only "active" inside TypeScript but not once the code has been compiled and running in a JavaScript environment.

So if we make email protected:

  class Contact implements HasEmail {
  constructor(
    public name: string,
    protected email: strong = "no email"
    ) {
      // nothing needed
    }
}

const x = new Contact('foo', 'bar');
// x.| shows only x.name but not x.email

Make email protected will also show a TypeScript warning, stating that the HasEmail interface has not been correctly impelemented.

You can make other fields protected, that are not part of the interface:

  class Contact implements HasEmail {
  protected age: number = 0; // protected is protected has a default value
  constructor(
    public name: string,
    public email: strong = "no email"
    ) {
      // nothing needed
    }
}

Declared fields must be initialised if no default value is provided:

  class Contact implements HasEmail {
  private password: string | undefined;
  constructor(
    public name: string,
    public email: strong = "no email"
    ) {
      this.password = "foo"
    }
}

// or, you can use the difinite assignment operator (`password!: string`)
// to tell TypeScript that the field will be initialised 
// conditionally or asynchronously but it WILL be initialised eventually
// Mike says this is useful to initialise fields in lifecycle hooks
class Contact implements HasEmail {
  private password!: string;
  constructor(
    public name: string,
    public email: strong = "no email"
    ) {
    }
    async init() {
      this.password = "foo"
    }
}

Another more extended solution to initialising a property is via the ES5 getter method:

  class Contact implements HasEmail {
  private password: string | undefined;
  constructor(
    public name: string,
    public email: strong = "no email"
    ) {
    }
  get password(): string {
    if(!this.passwordVal) {
      this.passwordVal = "foo";
    }
    return this.passwordVal;
  }
  async init() {
    this.password;
  }
}

Abstract classes

Abstract classes are "base" classes, that cannot be instantiated directly. They can declare abstract methods, that must be implemented by any subclass that extends from it.

Mike calls them half classes, half interfaces, because like interfaces, abstract classes don't have their implementations either.

  abstract class AbstractContact implements HasEmail, HasPhoneNumber {
  public abstract phone: number; // this must be impemented by subclasses (satisy HasPhoneNumber)
  
  constructor(
    public name: string,
    public name: string // this is to satisfy HasEmail
    ) {}
    
  abstract sendEmail(): void; // this has to be implemented by the subclass
}

Converting to JavaScript to TypeScript

The base rules are

  • don't change/add functionality while converting
  • make sure you have good enough test coverage
  • make sure to add tests for you types
  • try to be too narrow or too "perfect" at first

Step 1

Just convert you .js to .ts files and enable allow for implicit any types. Then go through the TypeScript errors and try to fix them.

Step 2

Ban any implicit anys: "noImplicitAny": true. Start adding the appropriate types. Import types from DefinetlyTyped. If not available add explicit anys.

Step 3

Start increasing the strict settings:

"strictNullChecks": true,
"strict": true,
"strictFunctionTypes": true, // validates the argument types and return types of callback functions
"strictBindCallApply": true

Generics

Generics allow us to parameterize types in the same way that functions paramterize values

The type that pass to our interface becomes a variable and we can reuse it elsewhere in our code, for example to define the return type, or as a type definition for a callback function

  interface WrappedValue<Foo> {
  value: Foo
}

let val: WrappedValue<string[]> = { value: [] };
// val.value| -> the editor will show that it's a `string[]`

// another example:

interface FilterFunction<T> { 
  (val: T): boolean; // function that takes a parameter type T, and returns a boolean
}

// using the interface
function stringFilter: FilterFunction<string>(val) {
  return typeof val === 'string'
}
stringFilter(0) // this throws an error
stringFilter('abc') // this is OK

A more advanced example, that shows how to pass a type parameter that will define the type that a Promise will resolve to:

  function resolveOrTimeout<T>(apromise: Promise<T>, timeout: number): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    // start the timeout, reject when it triggers
    const task = setTimeout(() => reject("time up!"), timeout);
    apromise.then(val => {
      // cancel the timeout
      clearTimeout(task);
      
      // resolve with the value
      resolve(val);
    });
  });
}

resolveOrTimeout(fetch(""), 3000); // fetch resolves to a Response object
// now, that we call resolveOrTimour with fetch, TypeScript will
// infer that the resolveOrTimeout takes/returns a Response

Constraining type parameters

You can refine the type parameter via the extends keyword. This lets you set a "base shape" for the type parameter, that is flexible enough to contain other properties as well.

  function arrayToDict<T extends { id: string }>(list: T[]): { [k: string]: T } {
  const out: { [k: string]: T } = {};
  list.forEach(val => {
    out[val.id] = val;
  });
  return out;
}

// the usage:
const myDict = arrayToDict([
  { id: "a", value: "first", lisa: "Huang" },
  { id: "b", value: "second" }
]);

By defining that <T extends {id: string}>, we guarantee that in the list.forEach part, out[val.id] will exist in our dictionary.

On the other hand, if we would type the arguments for arrayToDict as:

function arrayToDict(list: {id: string}[]) ....

We would not be able to pass the value and list properties (see myDict above).

Note, that you have to evaluate each time if you really need to use generics. It's useful when for example the type of an input argument is meaningful inside the function or appears somewhere in the output of the function. See the related FrontendMasters video

The following example shows such an example, where a generic would not make sense:

  interface Shape {
  draw();
}
interface Circle extends Shape {
  radius: number;
}

function drawShapes1<S extends Shape>(shapes: S[]) {
  shapes.forEach(s => s.draw());
}

function drawShapes2(shapes: Shape[]) {
  // this is simpler. Above type param is not necessary
  shapes.forEach(s => s.draw());
}

The generic would make sense, if you were returning something, so instead of forEach, you do .map:

  function drawShapes1<S extends Shape>(shapes: S[]) : S[] { // notice we added the return type
  shapes.forEach(s => s.draw());
  return shapes.map(s => {
    // ... do stuff
    s.draw();
    return s;
  })
}

Use example for mapping a dictionary

Here's an example written for the FrontendMasters course that implements a mapping function to be used on dictionary types. It's a great example of using type paramaters:

  type Dict<T> = {
  [foo: string]: T | undefined
}

function mapDict<T, S>(
  dict: Dict<T>,
  fn: (arg: T, idx: number) => S
): Dict<S> {
  const out: Dict<S> = {};
  Object.keys(dict).forEach((dKey, idx) => {
    const thisItem = dict[dKey];
    if (typeof thisItem !== 'undefined') {
      out[dKey] = fn(thisItem, idx);
    }
  });
  return out;
}

Top and bottom types

Top types

Top types are the "widest" types, e.g. any

The unknown type

It's similar to any in that it can take any JavaScript type. The difference is in accessing props. In the case of unkown type, TypeScript doesn't let you access the properties.

let myUnknown: unknown = "foo"

myUnknown.bar // not allowed in TypeScript 
Use cases

When you are building a library and don't want the users of your object to depend on the shape. For example setTimeout returns a number (the id of the timeout). Here it doesn't really matter if it's a number type or something else, you just use it and reference it.

You need to narrow down the type before you can use it, using type guards.

When to use any

When you need maximum flexibility. For example when we don't care about the return value of a Promose:

async function logWhenResolved(p: Promise<any>) {
  const val = await p;
  console.log("Resolved to: ", val);
}

Type guards

(Note, this section my need some revision, I may not have understood correctly the introduction)

Built-in type guards

(again, I'm not 100% sure of this) You can check for a type of a variable (via typeof or instanceof) and if it's true, the compiler will update the type of that variable inside that scope.

  let myUnknown: unknown = "foo";
if (typeof myUnknown === "string") {
  // in here, myUnknown is of type string
  myUnknown.split(", "); // ✅ OK
}
if (myUnknown instanceof Promise) {
  // in here, myUnknown is of type Promise<any>
  myUnknown.then(x => console.log(x));
}
User defined type guards

To create user defined type guards, you have to declare the return value of a function as function foo(bar: any): bar is HasEmail { ... }.

If the function returns true it's an indication for the TypeScript compiler that bar is of type HasEmail

  function isHasEmail(x: any): x is HasEmail {
  return typeof x.name === "string" && typeof x.email === "string";
}

if (isHasEmail(myUnknown)) {
  // In here, myUnknown is of type HasEmail
  console.log(myUnknown.name, myUnknown.email);
}

// my most common guard
function isDefined<T>(arg: T | undefined): arg is T {
  return typeof arg !== "undefined";
}

// use example, filter for defined items
const list = ['a', 'b', 'c', undefined, 'e'];
const filtered = list.filter(isDefined); // type here is filtered: string[]

Branded types and casting

Because unknown is a top type, this is possible:

let aa: unknown = 42;
let bb: unknown = "foo";
bb = aa;

Branding values with types can help you to "package" them. It's a pattern, where you cast (myval as MyType) a value to specific type. Usually these "branding" types are defined in one place in your codebase. The idea is to communicate that you intentionally wrapped an unknown value, like a mail envelope, where you don't see the contents but has a stamp on it – and you know where it came from.

To access the original value you need an unbranding function that is specific to that branded type. An unbranding function will not work from a different type.

  interface BrandedA {
  __this_is_branded_with_a: "a";
}
function brandA(value: string): BrandedA {
  return (value as unknown) as BrandedA;
}
function unbrandA(value: BrandedA): string {
  return (value as unknown) as string;
}

interface BrandedB {
  __this_is_branded_with_b: "b";
}
function brandB(value: { abc: string }): BrandedB {
  return (value as unknown) as BrandedB;
}
function unbrandB(value: BrandedB): { abc: string } {
  return (value as unknown) as { abc: string };
}

let secretA = brandA("This is a secret value");
let secretB = brandB({ abc: "This is a different secret value" });

secretA = secretB; // No chance of getting these mixed up, this will show an error
unbrandB(secretA); // not possible, the "brands" don't match up
unbrandA(secretB); // not possible

// back to our original values
let revealedA = unbrandA(secretA);
let revealedB = unbrandB(secretB);

Again, using this "branded" pattern is a way to communicate with other developers on now they should access it (not access it). Mike often uses it for id generation.

Bottom types

The bottom type is never. It cannot hold any value. It can still be useful, as a way to catch cases, branches in your code where you didn't expect your program to go through. So it's a great way to catch bugs at compile time.

"exhaustive switch": when you expect your code to test all possible cases. If a variable doesn't match any of the cases, then you can set up TypeScript to warn about it

  class UnreachableError extends Error {
  constructor(val: never, message: string) {
    super(`TypeScript thought we could never end up here\n${message}`);
  }
}

let y = 4 as string | number;

if (typeof y === "string") {
  // y is a string here
  y.split(", ");
} else if (typeof y === "number") {
  // y is a number here
  y.toFixed(2);
} else {
  // TypeScript will warn you if your code would go through here:
  throw new UnreachableError(y, "y should be a string or number");
}

The above code works with no error thrown. If you would widen the type of var y then you would arrive to the last case and would get an error:

  let y = 4 as string | number | boolean;

// UncreachableError would now be called 

Now, you would get an error from TypeScript that UnreachableError was called with a type boolean, and it expected never type (constructor(val: never, message: string) {...}).

Mapped types

This is a more flexible solution to overloading interfaces (see above). Instead of defining pairs of communication types ("email", "phone", "fax", see below) with acceptable data pairs, we create a map:

  interface CommunicationMethods {
  email: HasEmail;
  phone: HasPhoneNumber;
  fax: { fax: number };
}

// now we can just use the key and it's value to define the acceptable pairs:
function contact<K extends keyof CommunicationMethods>(
  method: K,
  contact: CommunicationMethods[K] // turning key into value -- a *mapped type*
) {
  //...
}
// all these work now
contact("email", { name: "foo", email: "mike@example.com" });
contact("phone", { name: "foo", phone: 3213332222 });
contact("fax", { fax: 1231 });

// we can get all values by mapping through all keys
type AllCommKeys = keyof CommunicationMethods;
type AllCommValues = CommunicationMethods[keyof CommunicationMethods];

Again, you could set up one place in your code, where these relationships are maintained and import them into your modules.

Type queries

You can get the type of a value using typeof

  const alreadyResolvedNum = Promise.resolve(4);

type ResolveType = typeof Promise.resolve;

const x: ResolveType = Promise.resolve;
x(42).then(y => y.toPrecision(2));

Ternary types

We can use conditionals and TypeScript infer to further refine the type of a variable

  type EventualType<T> = T extends Promise<infer S> // if T extends Promise<any>
  ? S // extract the type the promise resolves to
  : T; // otherwise just let T pass through

let a: EventualType<Promise<number>>;
let b: EventualType<number[]>;

"Grab the type of the Promise, or let it fall through if it's not a Promise"

Buit-In Utility Types

Partial utility type

Lets you create a object that only has a subset of the properties of the original interface:

  type MayHaveEmail = Partial<HasEmail>;
const me: MayHaveEmail = {}; // everything is optional
// MayHaveEmail can now contain any of the properties of HasEmail

Pick

It allows you to select one or more properties from an object type. Similar to many other utility libraries methods.

  type PickA = Pick<{a:1, b:2}, "b">
const y: PickA;
// y.|  -> it only has `.b` here

Extract

You can exclude based on type:

  type OnlyStrings = Extract<"a" | "b" | 1 | 2, number>;

Exclude lets us obtain a subset of types that are NOT assignable to something

type NotStrings = Exclude<"a" | "b" | 1 | 2, string>;

Record helps us create a type with specified property keys and the same value type

type ABCPromises = Record<"a" | "b" | "c", Promise<any>>;

Declaration merging

In TypeScript, identifiers (var, class, function, interface...) can be associated with three things: value, type or namespace.

function foo() {}
interface bar {}
namespace baz {
  export const biz = "hello";
}

Values can be used as an RHS assignment (right hand side). Functions and variables are purely values, and so their types can only be extracted using type queries.

// how to test for a value
const x = foo; // foo is in the value position (RHS).

// getting the type of a value
const yy: typeof xx = 4;

A type can only be on the LHS of an assignment. Interfaces are purely types, and so they cannot be on the RHS.

// how to test for a type
const y: bar = {}; // bar is in the type position (LHS).

// interface cannot be on the RHS:
const z = bar; // ERROR
// how to test for a namespace (hover over baz symbol)
baz;

export { foo, bar, baz }; // all are importable/exportable
Classes

Classes can be both, types and values. It can capture the class itself or it can be used as a type.

class Contact {
  name: string;
}

// passes both the value and type tests

const contactClass = Contact; // value relates to the factory for creating instances
const contactInstance: Contact = new Contact(); // interface relates to instances

If the class is used as a type, it can be used to describe the instances themselves.

Declaration merging

Declaration with the same name can be merged. In the following example Album can be all three, a value (class), an interface, and a namespace. The interface merges on the class, so you can stack an interface on the top of a class for example.

class Album {
  label: Album.AlbumLabel = new Album.AlbumLabel();
}
namespace Album {
  export class AlbumLabel {}
}
interface Album {
  artist: string;
}

let al: Album; // type test
let alValue = Album; // value test

// when you export Album, and hover over the value
// it's all three (class, interface, namespace)
export { Album }; // hover over the "Album" -- all three slots filled

To explain what "stacking" means, let's create a new "Album":

let al2 = new Album();
// al2.|  -> you see both .label (from the class) and .artist (from the interface)

So this is really useful if someone forgot to add all the type information on their library. You can stack the interface on it, to add the extra information. And it applies globally in your app.


Aside on how the types are stored in your app

The modifications you make to your types (e.g. declaration merging) are app wide, even if you don't export them explicitly, or if you are importing a module from somewhere.


Namespaces

A namespace is neither a class or an interface. They can be merged with classes or functions.

class AddressBook {
  contacts!: Contact[];
}
namespace AddressBook {
  // can create "inner classes"
  export class ABContact extends Contact {} // inner class
}

const ab = new AddressBook();
ab.contacts.push(new AddressBook.ABContact()); // refer to the innerclasss

These "inner class" are possible because namespace can merge with classes. These inner classes are a bit like the static properties (accessible with instantiating the class).


// 💡 or functions

function format(amt: number) {
  return `${format.currency}${amt.toFixed(2)}`;
}
namespace format {
  export const currency: string = "$ ";
}

format(2.314); // $ 2.31
format.currency; // $

Notice, that there's no collision above, with the namespace we can either invoke format(123) or access a property format.currency.


Aside on the static property

Not strictly related, but I'm happy that someone cleared this up. Classes in JavaScript can have a static property, that are "directly accessible".

This just means that these properties are accessible without running the class with the new keyword:

class Contact {
  name: string;
  static hello = "world";
}

// now you can access the static property:
Contact.hello // (porperty) Contact.hello: string