Skip to content

Instantly share code, notes, and snippets.

@dfreeman
Last active September 11, 2020 22:52
Show Gist options
  • Save dfreeman/33fc80164c0ad91d5e9480a94aa6454c to your computer and use it in GitHub Desktop.
Save dfreeman/33fc80164c0ad91d5e9480a94aa6454c to your computer and use it in GitHub Desktop.
Salsify Mirage types

Salsify Mirage Types

These types are written against the exports for ember-cli-mirage, but should map pretty cleanly to miragejs.

Test Context

You can add the following snippet to your project's test-helper.ts to expose this.server in tests, where <base> is the name of your app, or dummy for addons. (More details about the Registry follow below.)

import Server from  'ember-cli-mirage/server';
import { Registry } from '<base>/mirage/registry';

declare module 'ember-test-helpers' {
  interface TestContext {
    server: Server<Registry>;
  }
}

Mirage Model and Factory Type Registries

The Server and Schema classes exposed by Mirage use string values to instantiate models. Their corresponding types make use of a registry type that combines information about the available Factory and Model definitions to determine the effective type of a given Mirage record.

Supposing a Person model is set up with the following model and factory definitions:

// mirage/models/person.ts
import { Model } from 'ember-cli-mirage';

export default Model.extend({});
// mirage/factories/person.ts
import { Factory } from 'ember-cli-mirage';

export default Factory.extend({
  name: 'John Doe',
  age: (n: number) => Math.floor(20 * Math.random() + 10) + n,
});

To build a registry type that allows TypeScript to know the return type of server.create('person'), create a file at mirage/registry.ts:

// mirage/registry.ts
import * as Mirage from 'ember-cli-mirage';

type Models = {
  person: typeof import('./models/person').default;
};

type Factories = {
  person: typeof import('./factories/person').default;
};

export type Registry = Mirage.Registry<Models, Factories>;

As you introduce new models and factories, add entries for them in the Models and/or Factories types in this module in order to make them available when working with a Server or Schema instance.

Relationships

In order for belongsTo and hasMany relationships to have correct information, you also need versions of those functions that are aware of the registry type for your project. At the bottom of your registry.ts file, you can add:

export const belongsTo: Mirage.BelongsTo<Registry> = Mirage.belongsTo;
export const hasMany: Mirage.HasMany<Registry> = Mirage.hasMany;

The actual exported runtime values are Mirage's own belongsTo and hasMany functions, but the types declared for them limit the input strings to come from the available model types, and ensures the relationship itself has the correct type.

With these helpers in place, the Person model can be updated:

// mirage/models/person.ts
import { Model } from 'ember-cli-mirage';
import { belongsTo, hasMany } from '../registry';

export default Model.extend({
  parent: belongsTo('person'),
  children: hasMany('person'),
});

And now, code like this will typecheck:

const person = server.create('person', {
  name: 'b',
  parent: server.create('person', { name: 'a' })
});

person.parent.id; // string | undefined
person.parent.name; // string
person.parent.age; // number
// TypeScript Version: 3.5
declare module 'ember-cli-mirage' {
import Schema from 'ember-cli-mirage/orm/schema';
import Server from 'ember-cli-mirage/server';
import { ModelDefinition, ModelInstance, FactoryDefinition } from 'ember-cli-mirage/-types';
export { Registry, ModelInstance } from 'ember-cli-mirage/-types';
/**
* A fake HTTP request
*/
export class Request {
/** The request body, if defined */
public readonly requestBody: string | File;
/** The URL of the request */
public readonly url: string;
/** Any headers associated with the request, with downcased names */
public readonly requestHeaders: Record<string, string>;
/** Any parameter specified via dynamic route segments */
public readonly params: Record<string, string>;
/** Any query parameters associated with the request */
public readonly queryParams: Record<string, string>;
}
/**
* A fake HTTP response. May be returned from a Mirage route
* handler for finer-grained control over the response behavior.
*/
export class Response {
/**
* @param code The HTTP status code for this response
* @param headers Any custom headers to set in this response
* @param body Data to send in the response body
*/
public constructor(code: number, headers: Record<string, string>, body: string);
}
/**
* The base definition for Mirage models.
*
* Use `Model.extend({ ... })` to define a model's relationships
* (via `belongsTo()` and `hasMany()`) and any static default
* attribute values.
*/
export const Model: ModelDefinition;
/**
* The base definition for Mirage factories.
*
* Use `Factory.extend({ ... })` to define methods that
* will generate default attribute values when `server.create`
* or the corresponding `schema` method is called for this
* type.
*/
export const Factory: FactoryDefinition;
/**
* A collection of zero or more Mirage model instances.
*/
export class Collection<T> {
public length: number;
public modelName: string;
public models: Array<T>;
}
export type RelationshipOptions = {
inverse?: string | null;
polymorphic?: boolean;
};
/** The registry-aware type of Mirage's `belongsTo` function */
// prettier-ignore
export type BelongsTo<Registry> =
& (<K extends keyof Registry>(key?: K, options?: RelationshipOptions) => () => Registry[K] | undefined)
& (<K extends keyof Registry>(options: RelationshipOptions & { polymorphic: true }) => () => Registry[K] | undefined);
/** Declares a one-to-one relationship to another Mirage model type. */
export function belongsTo<T = ModelInstance>(key?: string, options?: RelationshipOptions): () => T | undefined;
export function belongsTo<T = ModelInstance>(options?: RelationshipOptions): () => T | undefined;
/** The registry-aware type of Mirage's `hasMany` function */
// prettier-ignore
export type HasMany<Registry> =
& (<K extends keyof Registry>(key?: K, options?: RelationshipOptions) => () => Collection<Registry[K]>)
& (<K extends keyof Registry>(options: RelationshipOptions & { polymorphic: true }) => () => Collection<Registry[K]>);
/** Declares a one-to-many relationship to another Mirage model type. */
export function hasMany<T = ModelInstance>(key?: string, options?: RelationshipOptions): () => Collection<T>;
export function hasMany<T = ModelInstance>(options?: RelationshipOptions): () => Collection<T>;
}
declare module 'ember-cli-mirage/-types' {
import { Collection } from 'ember-cli-mirage';
// Captures the result of a `Model.extend()` call
const ModelData: unique symbol;
type ModelDefinition<Data = {}> = {
extend<NewData>(data: NewData): ModelDefinition<Assign<Data, NewData>>;
[ModelData]: Data;
};
// Captures the result of a `Factory.extend()` call
const FactoryData: unique symbol;
type FactoryDefinition<Data = {}> = {
extend<NewData>(data: NewData): FactoryDefinition<Assign<Data, NewData>>;
[FactoryData]: Data;
};
// The type-level equivalent of `Object.assign`
type Assign<T, U> = U & Omit<T, keyof U>;
// Extract relationship values from a model definiton
type FlattenRelationships<T> = { [K in keyof T]: T[K] extends (() => infer Value) ? Value : T[K] };
// Extract factory method return values from a factory definition
type FlattenFactoryMethods<T> = { [K in keyof T]: T[K] extends (n: number) => infer V ? V : T[K] };
// Extracts model definition info for the given key, if a correesponding model is defined
type ExtractModelData<Models, K> = K extends keyof Models
? Models[K] extends ModelDefinition<infer Data>
? FlattenRelationships<Data>
: {}
: {};
// Extracts factory definition info for the given key, if a correesponding factory is defined
type ExtractFactoryData<Factories, K> = K extends keyof Factories
? Factories[K] extends FactoryDefinition<infer Data>
? FlattenFactoryMethods<Data>
: {}
: {};
/**
* Models all available information about a given set of model and
* factory definitions, determining the behavior of ORM methods on
* a `Server` and its corresponding `Schema` instance.
*/
export type Registry<
Models extends Record<string, ModelDefinition>,
Factories extends Record<string, FactoryDefinition>
> = {
[K in keyof Models | keyof Factories]: ModelInstance<
ExtractModelData<Models, K> & ExtractFactoryData<Factories, K>
>;
};
/** Represents the type of an instantiated Mirage model. */
export type ModelInstance<Data = {}> = Data & {
id?: string;
attrs: Record<string, unknown>;
modelName: string;
/** Persists any updates on this model back to the Mirage database. */
save(): void;
/** Updates and immediately persists a single attr on this model. */
update<K extends keyof Data>(key: K, value: Data[K]): void;
/** Removes this model from the Mirage database. */
destroy(): void;
/** Reloads this model's data from the Mirage database. */
reload(): void;
};
}
declare module 'ember-cli-mirage/server' {
import Schema from 'ember-cli-mirage/orm/schema';
import Db from 'ember-cli-mirage/db';
import { Request, Response, Collection } from 'ember-cli-mirage';
import { ModelInstance } from 'ember-cli-mirage/-types';
type MaybePromise<T> = T | PromiseLike<T>;
/** A callback that will be invoked when a given Mirage route is hit. */
export type RouteHandler<Registry> = {
(schema: Schema<Registry>, request: Request): MaybePromise<ModelInstance | Response | object | void>;
};
export type HandlerOptions = {
/** A number of ms to artifically delay responses to this route. */
timing?: number;
};
export default class Server<Registry = Record<string, ModelInstance>> {
/** The underlying in-memory database instance for this server. */
public readonly db: Db;
/** An interface to the Mirage ORM that allows for querying and creating records. */
public readonly schema: Schema<Registry>;
/** Creates a model of the given type. */
public readonly create: Schema<Registry>['create'];
/** Creaetes multiple models of the given type. */
public createList<K extends keyof Registry, Init extends Registry[K], Data extends Partial<Init>>(
modelName: K,
count: number,
data?: Data
): Array<Init & Data>;
/** Whether or not Mirage should log all requests/response cycles. */
public logging: boolean;
/** A default number of ms to artifically delay responses for all routes. */
public timing: number;
/** A default prefix applied to all subsequent route definitions. */
public namespace: string;
/** Handle a GET request to the given path. */
public get(path: string, handler?: RouteHandler<Registry>, options?: HandlerOptions): void;
/** Handle a POST request to the given path. */
public post(path: string, handler?: RouteHandler<Registry>, options?: HandlerOptions): void;
/** Handle a PUT request to the given path. */
public put(path: string, handler?: RouteHandler<Registry>, options?: HandlerOptions): void;
/** Handle a PATCH request to the given path. */
public patch(path: string, handler?: RouteHandler<Registry>, options?: HandlerOptions): void;
/** Handle a DELETE request to the given path. */
public del(path: string, handler?: RouteHandler<Registry>, options?: HandlerOptions): void;
/** Pass through one or more URLs to make real requests. */
public passthrough(...urls: Array<string>): void;
/** Load all available fixture data matching the given name(s). */
public loadFixtures(...names: Array<string>): void;
}
}
declare module 'ember-cli-mirage/db' {
/** The in-memory database containing all currently active data keyed by collection name. */
export default class Db {
[key: string]: unknown;
}
}
declare module 'ember-cli-mirage/orm/schema' {
import Db from 'ember-cli-mirage/db';
import { ModelInstance, Collection } from 'ember-cli-mirage';
type ModelInitializer<Data> = {
[K in keyof Data]: Data[K] extends Collection<infer M> ? Collection<M> | Array<M> : Data[K];
};
/**
* An interface to the Mirage ORM that allows for querying and creating records.
*/
export default class Schema<Registry = Record<string, ModelInstance>> {
/** Mirage's in-memory database */
public readonly db: Db;
/**
* Creates a model of the given type.
* @param modelName The type of model to instantiate
* @param data Optional initial values for model attributes/relationships
*/
public create<K extends keyof Registry, Init extends Registry[K], Data extends Partial<ModelInitializer<Init>>>(
modelName: K,
data?: Data
): Init & { [K in keyof Init & keyof Data]: Exclude<Init[K], undefined> };
/** Locates one or more existing models of the given type by ID(s). */
public find<K extends keyof Registry>(type: K, id: string): Registry[K] | null;
public find<K extends keyof Registry>(type: K, ids: Array<string>): Collection<Registry[K]>;
/** Locates an existing model of the given type by attribute value(s), if one exists. */
public findBy<K extends keyof Registry>(type: K, attributes: Partial<Registry[K]>): Registry[K] | null;
/** Locates an existing model of the given type by attribute value(s), creating one if it doesn't exist. */
public findOrCreateBy<K extends keyof Registry>(type: K, attributes: Partial<Registry[K]>): Registry[K];
/** Locates an existing model of the given type by attribute value(s), if one exists. */
public where<K extends keyof Registry>(type: K, attributes: Partial<Registry[K]>): Collection<Registry[K]>;
public where<K extends keyof Registry>(type: K, test: (item: Registry[K]) => unknown): Collection<Registry[K]>;
/** Returns a collection of all known records of the given type */
public all<K extends keyof Registry>(type: K): Collection<Registry[K]>;
/** Returns an empty collection of the given type */
public none<K extends keyof Registry>(type: K): Collection<Registry[K]>;
/** Returns the first model instance found of the given type */
public first<K extends keyof Registry>(type: K): Registry[K] | null;
}
}
declare module 'ember-cli-mirage/test-support/setup-mirage' {
/** Enables Mirage in the current test scope. */
export default function setupMirage(hooks: { beforeEach: Function; afterEach: Function }): void;
}
import { Model, Factory, Registry } from 'ember-cli-mirage';
import Schema from 'ember-cli-mirage/orm/schema';
const PersonModel = Model.extend({
name: 'hello',
});
const PersonFactory = Factory.extend({
age: 42,
height(n: number) {
return `${n}'`;
},
});
declare const schema: Schema<Registry<{ person: typeof PersonModel }, { person: typeof PersonFactory }>>;
const people = schema.all('person');
people.length; // $ExpectType number
people.modelName; // $ExpectType string
people.models.map(model => {
model.id; // $ExpectType string | undefined
model.name; // $ExpectType string
model.attrs; // $ExpectType Record<string, unknown>
model.age; // $ExpectType number
model.height; // $ExpectType string
model.foo; // $ExpectError
});
schema.create('person').height; // $ExpectType string
schema.create('person', {}).height; // $ExpectType string
schema.create('person', { height: 'custom' }).height; // $ExpectType string
schema.create('person', { height: 123 }); // $ExpectError
schema.create('person', { foo: 'bar' }); // $ExpectError
import { Model, Registry } from 'ember-cli-mirage';
import Schema from 'ember-cli-mirage/orm/schema';
const PersonModel = Model.extend({
name: 'hello',
});
declare const schema: Schema<Registry<{ person: typeof PersonModel }, {}>>;
const people = schema.all('person');
people.length; // $ExpectType number
people.modelName; // $ExpectType string
people.models.map(model => {
model.id; // $ExpectType string | undefined
model.name; // $ExpectType string
model.modelName; // $ExpectType string
model.attrs; // $ExpectType Record<string, unknown>
model.foo; // $ExpectError
model.save();
model.reload();
model.destroy();
model.update('name', 'goodbye');
model.update('name', false); // $ExpectError
model.update('bad', 'ok'); // $ExpectError
});
schema.create('person').name; // $ExpectType string
schema.create('person', {}).name; // $ExpectType string
schema.create('person', { name: 'custom' }).name; // $ExpectType string
schema.create('person', { name: 123 }); // $ExpectError
schema.create('person', { foo: 'bar' }); // $ExpectError
import { Model, Registry, belongsTo, hasMany, BelongsTo, HasMany } from 'ember-cli-mirage';
import Schema from 'ember-cli-mirage/orm/schema';
const registryBelongsTo: BelongsTo<PersonRegistry> = belongsTo;
const registryHasMany: HasMany<PersonRegistry> = hasMany;
const PersonModel = Model.extend({
name: 'hello',
parent: registryBelongsTo('person'),
pets: registryHasMany('pet'),
friends: registryHasMany<'pet' | 'person'>({ polymorphic: true })
});
const PetModel = Model.extend({
name: 'fido',
owner: registryBelongsTo('person'),
});
type PersonRegistry = Registry<{ person: typeof PersonModel; pet: typeof PetModel }, {}>;
declare const schema: Schema<PersonRegistry>;
const people = schema.all('person');
people.length; // $ExpectType number
people.modelName; // $ExpectType string
people.models.map(model => {
model.parent!.name; // $ExpectType string
model.parent!.parent!.name; // $ExpectType string
model.pets.models[0].name; // $ExpectType string
// Polymorphic relationship
const friend = model.friends.models[0];
// Both 'pet' and 'person' models have a name, but no other shared fields
friend.name; // $ExpectType string
friend.parent; // $ExpectError
friend.friends; // $ExpectError
friend.owner; // $ExpectError
if ('parent' in friend) {
// Here we know friend is a person
friend.parent!.name; // $ExpectType string
friend.friends.length; // $ExpectType number
} else {
// Here we know friend is a pet
friend.owner!.name; // $ExpectType string
}
});
const child = schema.create('person', {
parent: schema.create('person'),
});
// Here we know `parent` is defined because it was just passed in
child.parent.name; // $ExpectType string
schema.create('person', { parent: 'hi' }); // $ExpectError
const pet1 = schema.create('pet');
const pet2 = schema.create('pet');
// We can instantiate a hasMany with either an array or a collection
// Either way, the instance should have a collection.
const personWithPetsArray = schema.create('person', {
pets: [pet1, pet2],
});
personWithPetsArray.pets.modelName; // $ExpectType string
const personWithPetsCollection = schema.create('person', {
pets: schema.all('pet'),
});
personWithPetsCollection.pets.modelName; // $ExpectType string
schema.create('person', { pets: [child] }); // $ExpectError
schema.create('person', { pets: schema.all('person') }); // $ExpectError
import Schema from 'ember-cli-mirage/orm/schema';
declare const schema: Schema<{ foo: { attr: string }; bar: { prop: number } }>;
schema.create('foo').attr; // $ExpectType string
schema.create('foo', { attr: 'ok' }).attr; // $ExpectType string
schema.create('foo', { attr: 123 }); // $ExpectError
schema.create('foo', { x: true }); // $ExpectError
schema.create('cow'); // $ExpectError
schema.find('foo', '123'); // $ExpectType { attr: string; } | null
schema.find('foo', ['123']).models[0]; // $ExpectType { attr: string; }
schema.find('cow', '123'); // $ExpectError
schema.findBy('bar', {}); // $ExpectType { prop: number; } | null
schema.findBy('bar', { prop: 5 }); // $ExpectType { prop: number; } | null
schema.findBy('bar', { baz: 'hi' }); // $ExpectError
schema.findBy('cow', { attr: 'bar' }); // $ExpectError
schema.findOrCreateBy('foo', { attr: 'hi' }); // $ExpectType { attr: string; }
schema.findOrCreateBy('foo', { bar: true }); // $ExpectError
schema.findOrCreateBy('cow', { attr: 'bar' }); // $ExpectError
schema.where('foo', { attr: 'bar' }); // $ExpectType Collection<{ attr: string; }>
schema.where('foo', { bar: true }); // $ExpectError
schema.where('foo', foo => foo.attr === 'ok'); // $ExpectType Collection<{ attr: string; }>
schema.where('foo', foo => foo.x === 'ok'); // $ExpectError
schema.where('cow', { attr: 'bar' }); // $ExpectError
schema.all('foo'); // $ExpectType Collection<{ attr: string; }>
schema.all('cow'); // $ExpectError
schema.none('foo'); // $ExpectType Collection<{ attr: string; }>
schema.none('cow'); // $ExpectError
schema.first('foo'); // $ExpectType { attr: string; } | null
schema.first('cow'); // $ExpectError
import { Response } from 'ember-cli-mirage';
import Server from 'ember-cli-mirage/server';
export default function config(this: Server): void {
this.namespace = 'foo';
this.timing = 123;
this.logging = true;
this.get('/foo');
this.put('/foo');
this.post('/foo');
this.patch('/foo');
this.del('/foo');
this.passthrough('/_coverage/upload');
this.loadFixtures();
this.get('/test/:segment', (schema, request) => {
schema.db; // $ExpectType Db
request.params; // $ExpectType Record<string, string>
request.queryParams; // $ExpectType Record<string, string>
request.requestBody; // $ExpectType string | File
request.requestHeaders; // $ExpectType Record<string, string>
request.url; // $ExpectType string
if (Math.random()) {
return 'ok';
} else if (Math.random()) {
return new Response(200, { 'Content-Type': 'application/json' }, '{}');
} else if (Math.random()) {
return Promise.resolve(schema.create('foo'));
}
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment