Get : Typed

motivations for using types

edit

Refine existing types

When the type you have at hand is not precise enough, refine it with a predicate.

The types are too wide

In this program, user names come from two sources:

  1. a new user enters a name,
  2. a third party service returns a list of names.

For the purpose of this example two things are true:

  • a valid username is a non blank string with no whitespace at beginning or end,
  • the third party service is sloppy with their output.

Unfiltered names from the third party are available via the getDirtyData function from ./dirty-data. Its type signature is () => Array<string>.

The original program blithely processes invalid names.

get-username.ts
import {getDirtyData} from "./dirty-data";

export const basicPrompt =
    async (title : string = "Choose a username") : Promise<string> =>
    (await $gt.prompt(title, "username")) || "";

export const hello = (name : string) : void =>
    $gt.log("hello", name);

export const goodbye = (name : string) : void =>
    $gt.log("bye", name);

export const run = async () : Promise<void> => {
    const name = await basicPrompt();
    hello(name);
    for (const n of getDirtyData()) {
        hello(n);
        goodbye(n);
    }
};

This is not desirable. The hello function must act on valid input in order for its message to look good (no extra spaces) and make sense.

Clearly we need some validation.

validation-helpers.ts
export const isValidUsername = (name : string) : boolean =>
    /^\S(.*\S)?$/.test(name);

export const assertValidUsername = (name : string) : void => {
    $gt.assert(isValidUsername(name), `Invalid username: '${name}'.`);
};

Argument validation

We can validate inside the called function.

assert-at-use-site.ts
import {basicPrompt, goodbye} from "./get-username";
import {getDirtyData} from "./dirty-data";
import * as v from "./validation-helpers";

export const hello = (name : string) : void => {
    v.assertValidUsername(name);
    $gt.log("hello", name);
};

export const run = async () : Promise<void> => {
    const name = await basicPrompt();
    hello(name);
    for (const n of getDirtyData()) {
        hello(n);
        goodbye(n);
    }
};

There are two problems with this:

  1. Calls to hello still fail (though no longer silently), and by the time the error is thrown the program might not be in a position to re-show the input dialog.
  2. The type system does not help us. The same validation needed to be added to every function taking this kind of argument but the compiler didn’t notice that we didn’t add it to goodbye.

Input validation

We could instead validate the user input.

validate-at-input-site.ts
import {hello, goodbye, basicPrompt} from "./get-username";
import {getDirtyData} from "./dirty-data";
import * as v from "./validation-helpers";

export const validatingPrompt =
    async (test? : (s : string) => boolean) : Promise<string | undefined> => {
        test = test || v.isValidUsername;
        let name = await basicPrompt();
        let count = 5;
        while (!test(name) && count-- > 0) {
            name = await basicPrompt("Invalid username, choose again");
        }
        return test(name) ? name : undefined;
    };

export const run = async () : Promise<void> => {
    const name = await validatingPrompt();
    if (name) {
        hello(name);
    }
    else {
        await $gt.alert("Out of tries.");
    }
    for (const n of getDirtyData()) {
        hello(n);
        goodbye(n);
    }
};

The type system doesn’t help us here either. We didn’t add validation to getDirtyData and only discovered the oversight on running the program.

The type is too wide

string is not the right type for our user names. To be used safely it requires that all inputs and arguments be validated. This is possible but suffers from a lack of type system support. Forget to validate an argument? You’ll find out at runtime. Update isValidUsername because you got it wrong the first time? You might discover at runtime that some functions were using the equally broken bobsMagicValidator instead.

Narrow the type

A refined type is essentially a type paired with a predicate. They are tied together by a “smart constructor” which allows only valid instances to be constructed.

Here is Username, a refinement of string. The only way to construct a user is with the mkUser function which throws on invalid input. This creates the compile time guarantee that all Username values are valid.

This refinement has zero runtime overhead. There is no wrapping going on. All the original type’s properties can be accessed on values of the refined type.

username-type.ts
import * as v from "./validation-helpers";

export type Tag<A> = {
    "@tag" : A;
};

export type Refined<A, T> = A & Tag<T>;

const enum _Username {}

export type Username = Refined<string, _Username>;

export const isUsername =
    (name : string) : name is Username =>
    v.isValidUsername(name);

export const mkUsername = (name : string) : Username => {
    if (!isUsername(name)) {
        throw new Error(`Invalid username ${name}.`);
    }
    return name;
};

With this refined type no argument validation inside hello and goodbye is necessary. Inside those functions the argument is guaranteed to be a Username and Username values are guaranteed valid by construction.

The type system will enforce that callers provide a Username argument. This enforcement will propagate up the call stack to the point where a Username is constructed. Much like an exception, but at compile time!

If we forget to validate a string to a Username in some input (like getDirtyData), the compiler will tell us.

refine-the-type.ts
import {validatingPrompt} from "./validate-at-input-site";
import {getDirtyData} from "./dirty-data";
import {Username, isUsername, mkUsername} from "./username-type";

export const refinedPrompt = async () : Promise<Username | undefined> => {
    const name = await validatingPrompt(isUsername);
    return name ? mkUsername(name) : undefined;
};

export const hello = (name : Username) : void =>
    $gt.log("hello", name);

export const goodbye = (name : Username) : void =>
    $gt.log("bye", name);

export const getData = () : Array<Username> =>
    getDirtyData().filter(isUsername).map(mkUsername);

export const run = async () : Promise<void> => {
    const name = await refinedPrompt();
    if (name) {
        hello(name);
    }
    else {
        $gt.alert("Out of tries.");
    }
    // for (const n of getDirtyData()) { //! toggle comments to show
    for (const n of getData()) {         //! compiler enforcement
        hello(n);
        goodbye(n);
    }
};

Where next?

  • Either or Validation for validated construction of a refined type
  • Safe strings for safe interpolation
  • General tag type for easy refined type creation