ericcodes ericcodes - 3 months ago 22
Javascript Question

Flowtype: generic Id<T> type with similar constraints to the type argument passed in

I have a generic

Id<T: HasId>
type which is structurally always just a
string
regardless of the type argument passed in as
T
. I'd like
Id<T>
types with different types passed as
T
to behave like different types.

For example, I would like the snippet
const i :Id<Car> = p.id
in the following code to cause a Flow error:

declare interface HasId {
id: string,
};

type Id<T: HasId> = string;

type Person = {
id: Id<Person>,
name: string,
};

type Car = {
id: Id<Car>,
make: string,
model: string,
};

const p :Person = { id: '1234', name: 'me' }

const c :Car = p; // Causes a Flow error, good!

const c :Id<Car> = p.id; // I want this to cause a Flow error,
// but currently it doesn't.


Furthermore, it would be nice if this could continue to work nicely with union types:

type Vehicle =
| Car
| Motorcycle
;

const t :Car = { id: '5678', make: 'Toyota', model: 'Prius' };

const v :Id<Vehicle> = c.id; // Currently does not cause Flow
// error; I want to keep it that way.

Answer

The pretty-good-but-sorta-hacky solution

I did some experimentation and found a way to do what I specified in the question based on the system shown in this GitHub issue comment and the one following it. You can use a class (with a generic type parameter T) which Flow treats an opaque type, and use casting to any in order to convert between string and ID.

Here are some utilities that enable this:

// @flow

import { v4 } from 'node-uuid';

// Performs a "type-cast" from string to Id<T> as far as Flow is concerned,
//   but this is a no-op function
export function stringToId<T>(s :string):Id<T> {
  return (s :any);
}

// Use this when you want to treat the ID as a string without a Flow error
export function idToString(i :Id<*>):string {
  return (i :any);
}
export function createId<T>():Id<T> {
  return stringToId('1234');
}

// Even though all IDs are strings, this type distinguishes between IDs that
//   can point to different objects.
export class Id<T> {};

With these utilities, the following code (similar to the original code in my question) will result in a Flow error, like I wanted.

// @flow
const p :Id<Person> = createId<Person>();
// note: Even though p is Id<Person> in Flow, its actual runtime type is string.

const c :Id<Car> = p; // this causes Flow errors. Yay!

// Also works without an explicit annotation for `p`:
const pp = createId<Person>();
const cc :Id<Car> = pp; // also causes Flow errors. Yay!

Downsides

The Flow output is unfortunately quite verbose, since type errors like this trigger multiple Flow errors. Even though the output isn't ideal, at least it behaves correctly in that making an error causes Flow to report an error.

Another issue is that with this solution, you have to explicitly convert from ID to string in cases such as object/map keys where Flow is not expecting an Id<*>, like this example:

// @flow
type People = { [key :string]: Person };

const people :People = {};

const p :Id<Person> = createId<Person>();

people[p] = createPerson(); // causes Flow error
// Unfortunately you always have to do this:
people[idToString(p)] = createPerson(); // no Flow error

These type conversion functions are just no-ops at runtime since all the Flow types are stripped out, so there may be a performance penalty if you call them a lot. See the GitHub issue I linked in this answer for some more discussion.

Note: I'm using Flow v0.30.0.

Comments