Skip to content

Instantly share code, notes, and snippets.

@sliminality
Last active July 9, 2019 19:18
Show Gist options
  • Save sliminality/0ab5d8ec0c3ecf90d3f8e6eec9e8e6d4 to your computer and use it in GitHub Desktop.
Save sliminality/0ab5d8ec0c3ecf90d3f8e6eec9e8e6d4 to your computer and use it in GitHub Desktop.
adventures in Flow typing

Erasing properties from objects

export type LearningTimeInfo = $ReadOnly<{|
    // The number of seconds offset from the learner UTC time to their
    // local time, to allow for in/out school detection.
    localTimeOffsetSeconds: {offsetSeconds: number},

    // The full URL.  Intended for debugging/human-readability.  Code
    // should not be analyzing this field to do things! -- instead we
    // should be adding a new field to the protobuf that holds the
    // needed data.
    urlForDebugging: string,

    // What product is the learner using?  Test-prep, missions, etc.
    product?: LearningTimeProduct,

    // Activity to which Learning Time will be credited.
    activity?: LearningTimeActivity,

    // Attributes of the learner's school(s); null if this learner is not
    // assigned to a school.  A learner may have multiple schools; index 0
    // will always be the "primary" school with which they are associated.
    schoolInfo?: $ReadOnlyArray<LearningTimeSchoolInfo>,

    // Associated content path, if one exists.
    content?: LearningTimeContent,

    testPrep?: LearningTimeTestPrep,
|}>;

/**
 * Represents a partially-complete LearningTimeInfo struct.
 * This gets propagated down the component hierarchy, accumulating more
 * specific information as we know it.
 */
export type PartialLearningTimeInfo = $ReadOnly<{|
    ...$Rest<
        // First, make every field of LearningTimeInfo optional...
        Partial<LearningTimeInfo>,
        // Then, subtract the `content` and `testPrep` fields...
        {|
            // NOTE(slim): Using `field: void | T` instead of `field?: T`
            // ensures that $Rest<A, B> removes `field` completely from A.
            content: void | LearningTimeContent,
            testPrep: void | LearningTimeTestPrep,
        |},
    >,
    // Finally, add `content` and `testPrep` back, making all of _their_ fields
    // optional.
    content?: Partial<LearningTimeContent>,
    testPrep?: Partial<LearningTimeTestPrep>,
|}>;

Exact object types catch bugs

type _Conversion<ID: string, E: {} = {}> = {
    id: ID,
    extras?: E,
};

conversions.forEach(function(conversion) {
    this.props.markConversion({
        id: conversion.id,
        ...this.props.bigBingoExtras,
        ...(conversion.extra ? conversion.extra : {}),
        ...learningTimeInfo,
    });
});

Filtering

function filter<T, P: $Pred<1>>(arr: Array<T>, pred: P): Array<$Refine<T, P, 1>> {
  return arr.filter(pred);
}

type A = { which: "A", data: number }
type B = { which: "B", data: string }
type C = { which: "C", data: boolean }

const isA  = (x): %checks => x.which === "A";
const isAC = (x): %checks => x.which !== "B";

function filterDemo(arr: Array<A | B | C>) {
	(filter(arr, isA): Array<A>);
  	(filter(arr, isA): Array<B | C>); // errors correctly: B and C have been filtered out
  
	(filter(arr, isAC): Array<A | C>);
  	(filter(arr, isAC): Array<B>); // errors correctly: B has been filtered out
}

Keeping inferred types as specific as possible

Use $Keys<T> instead of $Values<T>

Try Flow

const viewModes = {
    default: "default",
    compact: "compact",
    maximized: "maximized",
};

// tracks refinements -- will error
("aaa": $Keys<typeof viewModes>);

// equivalent to type ViewMode$Values = string;
// doesn't error (unsound)
("aaa": $Values<typeof viewModes>);

Cast after Object.keys()

Normally, Object.keys() loses type information:

const viewModes = {
    default: "default",
    compact: "compact",
    maximized: "maximized",
};

const ks = Object.keys(viewModes);  // ks has inferred type Array<string>

We can confirm that ks has inferred type Array<string> with an equality check:

// The following lines establish type equality between `typeof ks` and `Array<string>`
(ks: Array<string>);
declare var _: Array<string>;
(_: typeof ks);

By casting the call to Object.keys() to Array<$Keys<typeof viewMode>>, we retain more specific information about the contents of ks:

const viewModes = {
    default: "default",
    compact: "compact",
    maximized: "maximized",
};

const ks = (Object.keys(viewModes): Array<$Keys<typeof viewModes>>);

(ks: Array<$Keys<typeof viewModes>>);
declare var _: Array<$Keys<typeof viewModes>>;
(_: typeof ks);

though I'm still not sure what this gets us.

Miscellaneous

  • Object.assign() has a useless any type: (source)

Creating one-to-one mappings between types

/**
 * Define a 1:1 mapping between LearningTimeContentKind and
 * LearningTimeContentKind$Server.
 *
 * When you add a new type to enums.ContentKind, you need to add the
 * corresponding SCREAMING_SNAKE_CASE type to enums.ContentKind$Server, then
 * add both types to this map. The Flow assertions below will statically
 * validate these changes.
 *
 * ACHTUNG(slim): DO NOT annotate the type of this object with an indexer
 * property, e.g. {[LearningTimeContentKind]: LearningTimeContentKind$Server} !!
 *
 * When an object is annotated with an indexer property, Flow will assume that
 * all lookups with the correct type are safe, even if the object does not
 * include the key. That means it's possible to add a type to the ContentKind
 * enum and forget to add it to the map here.
 * There is a check below to guard against this.
 * https://github.com/facebook/flow/issues/6568#issuecomment-447543153
 */
export const mapContentKindToServer = {
    Topic: "TOPIC",
    Exercise: "EXERCISE",
    Article: "ARTICLE",
    Video: "VIDEO",

    Challenge: "CHALLENGE",
    Talkthrough: "TALKTHROUGH",
    Project: "PROJECT",
    Interactive: "INTERACTIVE",

    TopicQuiz: "TOPIC_QUIZ",
    TopicUnitTest: "TOPIC_UNIT_TEST",
};

/**
 * ACHTUNG(slim): Modify the code below with extreme care! It verifies the
 * following properties of mapContentToServer:
 *
 * 1. (Soundness) All values in the domain and codomain have the correct type.
 * 2. (Completeness) Any time a new key is added to the ContentKind enum, it is
 *    added to mapContentKindToServer.
 */

// Static check for soundness.
// Ensures every key in `mapContentKindToServer` is a valid ContentKind, that
// is, no superfluous keys are defined.
// This is NOT semantically equivalent to annotating the variable declaration,
// as we will see shortly...
(mapContentKindToServer: {
    [LearningTimeContentKind | "__TEST_ONLY"]: LearningTimeContentKind$Server,
});

// An annoying behavior of Flow is that annotating an object declaration with an
// indexer property, e.g. {[K]: T}, will cause ANY lookup with key type K to
// succeed -- even if the key is not actually defined in the object at runtime!
// This is why we instead use the preceding type assertion.
//
// The following assertion ensures that no one accidentally refactors the
// annotation into the declaration.
// It does so by looking up a nonexistent property whose type we added to
// the domain of `mapContentKindToServer`.
// If Flow warns that this suppression is unused, we are in trouble.
// $FlowFixMe
(mapContentKindToServer.__TEST_ONLY: LearningTimeContentKind$Server);

// Static check for completeness.
// This final check ensures that all possible ContentKinds are included in
// `mapContentKindToServer`.
(a: LearningTimeContentKind): LearningTimeContentKind$Server =>
    mapContentKindToServer[a];

Keeping runtime values up to date with unions

Consider the following code, which behaves as expected:

type Union = "A" | "B" | "C";
declare function randomElement<T>(xs: Array<T>): T;
(randomElement(["A", "B", "C"]): Union);

As expected, attempting to do (randomElement(["A", "B", "D"]): Union); fails to typecheck:

4: (randomElement(["A", "B", "D"]): Union);
    ^ Cannot cast `randomElement(...)` to `Union` because string [1] is incompatible with enum [2].
    References:
    4: (randomElement(["A", "B", "D"]): Union);
                                 ^ [1]
    4: (randomElement(["A", "B", "D"]): Union);
                                        ^ [2]

Problem

Suppose we later extend the definition of Union, adding a "D" case:

type Union = "A" | "B" | "C" | "D";

Now our array ["A", "B", "C"] is not exhaustive, and there's no easy solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment