Skip to content

Instantly share code, notes, and snippets.

@gitKrystan
Last active November 7, 2023 18:43
Show Gist options
  • Save gitKrystan/16aa9fa8a1ff7fef8ec5649a53151053 to your computer and use it in GitHub Desktop.
Save gitKrystan/16aa9fa8a1ff7fef8ec5649a53151053 to your computer and use it in GitHub Desktop.
ember-cli-mirage types

Skylight ember-cli-mirage Types

These types were written by the team at https://www.skylight.io/, specifically @gitKrystan (with lots of help from @chancancode).

Gist filenames don't allow slashes, so I replaced them with :.

Before you start

Add "<app-name>/mirage/*": ["mirage/*"] to your paths config in your tsconfig.json

The Mirage Registry

The MirageJS types expect you to create a registry of Mirage Models and Factories for Mirage's string key lookups (e.g. this.server.create('user');. The registered Mirage Models will be used for determining relationship types, and the Factories for determining attribute types.

When using ember-cli-mirage, Mirage will automatically discover your models and their relationships, so you don’t need to define any files within Mirage yourself. Unfortunately, the MirageJS types appear to expect Mirage models to be explicitly defined. This gist provides a ConvertModelRegistry utility type to convert the ModelRegistry provided by ember-cli-typescript into an interface of Mirage ModelDefinitions for use in the Mirage Registry.

See mirage:registry.ts below for an example Mirage Registry.

Mirage Factory Types

The Mirage Registry will extract attribute types from your Mirage Factories. But sometimes the default factory value types don't encompass all of the valid types for that attribute.

For example, imagine you have this User Factory:

// mirage/factories.user.ts

import { Factory } from 'ember-cli-mirage';

export default Factory.extend({
  fullName: 'Test (development) Shared',
  githubUsername: null,
});

The Mirage Registry will extract something like:

interface UserAttrs {
  fullName: string;
  githubUsername: null;
}

And something like this.server.create('user', { githubUsername: 'gitKrystan' }); will result in a type error.

To account for this, mirage:factories:types:attr.ts provides types for each of the Ember transforms, plus an attribute function for arbitrary attributes and a nullable function for nullable attributes.

// mirage/factories.user.ts

import { Factory } from 'ember-cli-mirage';
import { booleanAttr, stringAttr, StringAttr, nullable } from './types/attr';

export default Factory.extend({
  fullName: stringAttr('Test (development) Shared'),
  githubUsername: nullable(stringAttr),
});

And now the extracted interface will look something like:

interface UserAttrs {
  fullName: string;
  githubUsername: string | null;
}

See mirage:factories:user.ts below for an example Factory.

Example Test Usage

Declare this: MirageTestContext to let TypeScript know you expect to have access to this.server in your test.

See tests:acceptance:my-acceptance-test.ts below.

Gotchas

We've noticed that our Mirage types can be noticeably slow to compile. We suspect this is due to the work required to instantiate the Mirage Registry:

  1. In order to create the registry, the Mirage types have to create all of the relevant model and factory types.
  2. Relationships can cause circular references. For example, if a user has many blog posts and each blog post has a user, does TypeScript instantiate the Post and User types many times?

Mirage Schema methods

Mirage adds a key to the Schema object for each of your models (e.g. this.server.schema.users) with a value of the DbCollection for that model. Unfortunately, these keys are not represented in the Mirage types, and doing so would be difficult because the Mirage registry keys are singular (e.g. 'user') while the Schema keys are plural (e.g. 'users').

Instead, I recommend using alternative string-key lookup methods on the Mirage Schema object directly. For example:

// before:
// Type-checking error: Property `users` does not exist on type `Schema`
this.server.schema.users.find(1);

// after:
this.server.schema.find('user', 1);

Resources/Inspiration

/**
* Given an object that looks like:
* ```ts
* interface Person {
* firstName: string;
* lastName: string;
* age: number;
* admin: boolean;
* }
* ```
*
* To filter for all the attributes with a given type,
* ```ts
* type KeysOfPersonThatAreStrings = FilterKeysByType<Person, string>; // => 'firstName' | 'lastName'
* ```
*
* Implementation:
*
* ```ts
* type KeysOfPerson = keyof Person; // => 'firstName' | 'lastName' | 'age' | 'admin';
*
* type ValuesOfPerson = Person[KeysOfPerson]; // == Person['firstName' | 'lastName' | 'age']
* // == Person['firstName'] | Person['lastName'] | Person['age'] | Person['admin']
* // == string | string | number | boolean
* // => string | number | boolean
*
* // Note that TS collapses unions to a minimum, removing any redudant types, e.g.
* type A = string | 'foo'; // => string (because 'foo' is already covered by string)
* type B = number | 1; // => number (likewise)
* type C = string | 'foo' | 'bar' | number | 1 | 2; // => string | number
* type D = string | number | never; // => string | number (`never` in unions are like `cond || false` in JS, it's a no-op)
*
* // Putting all of this together, we first convert `Person` to an intermediate form like this:
*
* interface IntermediatePersonThingy {
* firstName: 'firstName';
* lastName: 'lastName';
* age: never;
* admin: never;
* }
*
* // For all the fields that have the type we are looking for, we replaced their types with the key.
* // Otherwise, we replace their types with `never`.
* // Knowing that we can get a union of all the values of this with `IntermediatePersonThingy[keyof Person]`,
* // AND that TS collapses away any `never`s in an union, we can get our final result with:
*
* type KeysOfPersonThatAreStrings = IntermediatePersonThingy[keyof Person];
* ```
*/
export type FilterKeysByType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
import { Init } from 'ember-cli-mirage';
import { MomentInput } from 'moment';
import { Registry } from '<app-name>/mirage/registry';
/**
* Allows us to specify types for Factory properties beyond the type of the
* default value for the property.
*
* @example
* ```ts
* # mirage/factories/foo.ts
*
* import { Factory } from 'ember-cli-mirage';
*
* import { attribute } from '<app-name>/mirage/factories/types/attr';
*
* export default Factory.extend({
* numberOrString: attribute<number | string>('i-am-a-string')
* });
* ```
*
* Without the `attribute` helper, Mirage would only allow:
* `server.create('foo', { numberOrString: 'also-a-string' });`,
* but with the helper, we can also do:
* `server.create('foo', { numberOrString: 2 });`
*
* @param defaultValue The value to use in the factory definition
*/
export function attribute<T>(defaultValue: T): T {
return defaultValue;
}
/**
* Allows us to specify nullable types for Factory properties beyond the type
* of the default value for the property.
*
* @example
* ```ts
* # mirage/factories/foo.ts
*
* import { Factory } from 'ember-cli-mirage';
*
* import { nullable, booleanAttr } from 'direwolf/mirage/factories/types/attr';
*
* export default Factory.extend({
* example1: nullable(booleanAttr), // returns `null` with type 'BooleanAttr | null'
* example2: nullable(booleanAttr(true)), // returns `true` with type 'BooleanAttr | null'
* example3: nullable<string | number>(), // returns `null` with type 'string | number | null'
* example4: nullable<string | number>(42), // returns `42` with type 'string | number | null'
* });
* ```
*
* Without the `nullable` helper, Mirage would only allow:
* `server.create('foo', { nullableString: null });`,
* but with the helper, we can also do:
* `server.create('foo', { nullableString: 'i-am-a-string' });`
*
* @param [firstArg] An attr function OR the value to use in the factory definition
* @param [secondArg] The value to use in the factory definition
*/
export function nullable<T>(
firstArg?: T | ((v: T) => T) | null,
secondArg?: T | null
): T | null {
if (secondArg) {
return secondArg;
} else if (firstArg && !(typeof firstArg === 'function')) {
return firstArg;
} else {
return null;
}
}
/**
* A placeholder for belongsTo relationships in Factories. Use optionally and as
* necessary so that usages of the relationship in other properties will
* typecheck.
*
* @example
* ```ts
* # mirage/factories/foo.ts
*
* import { Factory } from 'ember-cli-mirage';
*
* import { belongsToPlaceholder } from 'direwolf/mirage/factories/types/attr';
*
* export default Factory.extend({
* bestFriend: belongsToPlaceholder<'person'>(),
*
* bestFriendName(): string {
* return this.bestFriend.name;
* }
* });
*/
export function belongsToPlaceholder<K extends keyof Registry>():
| Init<K>
| undefined {
return (null as unknown) as Init<K> | undefined;
}
// Server types accepted by the various transforms
/**
* Server types allowed for
* `@attr('boolean') myProp!: boolean;`
*/
export type BooleanAttr = boolean | string | number;
/**
* Server types allowed for
* `@attr('boolean') myProp!: boolean;`
*/
export function booleanAttr(defaultValue: BooleanAttr): BooleanAttr {
return defaultValue;
}
/**
* Server types allowed for
* `@attr('date') myProp!: Date;`
*/
export type DateAttr = string | number;
/**
* Server types allowed for
* `@attr('date') myProp!: Date;`
*/
export function dateAttr(defaultValue: DateAttr): DateAttr {
return defaultValue;
}
/**
* Server types allowed for
* `@attr('number') myProp!: number;`
*/
export type NumberAttr = string | number;
/**
* Server types allowed for
* `@attr('number') myProp!: number;`
*/
export function numberAttr(defaultValue: NumberAttr): NumberAttr {
return defaultValue;
}
/**
* Server types allowed for
* `@attr('string') myProp!: string;`
*/
export type StringAttr = string;
/**
* Server types allowed for
* `@attr('string') myProp!: string;`
*/
export function stringAttr(defaultValue: StringAttr): StringAttr {
return defaultValue;
}
// An example factory
import { Factory, trait } from 'ember-cli-mirage';
import { booleanAttr, stringAttr, StringAttr, nullable } from './types/attr';
export default Factory.extend({
fullName: stringAttr('Test (development) Shared'),
nickname: stringAttr('Tester'),
nicknameConfirmed: booleanAttr(true),
email(i: number): StringAttr {
if (i) {
return `user-${i}@example.com`;
} else {
return 'shared@example.com';
}
},
githubUsername: nullable(stringAttr),
githubConnected: trait({
githubUsername(i: number) {
return `github-user-${i}`;
},
email() {
return `${(this.githubUsername as unknown) as string}@example.com`;
},
}),
});
import { assert } from '@ember/debug';
import { Server } from 'ember-cli-mirage';
/**
* Provides access to the Mirage server outside of test contexts (e.g. in a Factory).
* If you are in a test, use MirageTestContext instead.
*/
export const MirageContext = {
get server(): Server {
let { server } = window as { server?: Server };
assert('Expected a global `server` but found none', server);
return server;
},
};
import EmberArray from '@ember/array';
import ArrayProxy from '@ember/array/proxy';
import EmberDataModel, { AsyncBelongsTo, AsyncHasMany } from '@ember-data/model';
import { BelongsTo, HasMany, ModelDefinition } from 'ember-cli-mirage/-types';
import { FilterKeysByType } from 'app/types/util';
/**
* Converts the given Ember Data Model Registry into a Mirage Model Registry.
*
* @example
* Given the following Registry:
* ```ts
* import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
*
* export default class User extends Model {
* @hasMany('post')
* declare posts: AsyncHasMany<Post>;
* }
*
* export default class User extends Model {
* @belongsTo('user')
* declare user: AsyncBelongsTo<User>;
* }
*
* export default interface ModelRegistry {
* user: User;
* post: Post;
* }
* ```
*
* We can generate the Mirage model Registry with:
* ```ts
* declare const model: ConvertModelRegistry<ModelRegistry>;
* ```
*
* The resulting Mirage model Registry will look like:
* ```
* type Registry = {
* user: ModelDefinition<{ posts: HasMany<'post'> }>;
* post: ModelDefinition<{ posts: BelongsTo<'user'> }>;
* }
* ```
*
* Note that only relationships are included on the Mirage model data type.
* We will get types from `@attr`s from Factories in the Mirage Registry,
* and getters should not be included in Mirage types bc they don't exist on the
* backend.
*/
export type ConvertModelRegistry<EmberDataModelRegistry> = {
[K in keyof EmberDataModelRegistry]: ModelDefinitionFor<
EmberDataModelRegistry,
K
>;
};
/**
* Wraps the `ModelDefinitionData` for the given Ember Data Model Registry and
* model key in Mirage's `ModelDefinition` type, which is necessary for the
* Mirage Registry to recognize the data as a Mirage model.
*/
type ModelDefinitionFor<
EmberDataModelRegistry,
K extends keyof EmberDataModelRegistry
> = ModelDefinition<
ModelDefinitionData<EmberDataModelRegistry, K> & { id: string }
>;
/**
* Converts an Ember Data model into Data for a Mirage model definition.
*
* @example
* Given the following Ember Data model for a user:
* ```ts
* import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
*
* export default class User extends Model {
* @belongsTo('organization')
* declare organization: PromiseRecord<Organization>;
*
* @hasMany('user')
* declare friends: PromiseManyArray<User>;
*
* @attr('string')
* declare name: string;
*
* @attr('number')
* age?: number;
*
* get description(): string {
* return `User ${name} is ${age} years old.`
* }
* }
*
* export default interface ModelRegistry {
* user: User;
* }
* ```
*
* We can generate the Mirage model data for the User model with:
* ```ts
* declare const model: ModelDefinitionData<ModelRegistry, 'user'>;
* ```
*
* The resulting Mirage model data type will look like:
* ```
* import { BelongsTo, HasMany } from 'miragejs/-types';
*
* type UserMirageModel = {
* organization: BelongsTo<'organization'>;
* friends: HasMany<'friend'>;
* }
* ```
*
* Note that the `@attr` properties and the `description` getter from the Ember
* Data model are not included in the Mirage model data type. We will get types
* from `@attr`s from Factories in the Mirage Registry, and getters should not
* be included in Mirage types bc they don't exist on the backend.
*/
export type ModelDefinitionData<
EmberDataModelRegistry,
K extends keyof EmberDataModelRegistry
> = EmberDataModelRegistry[K] extends EmberDataModel
? MapEmberDataRelationshipsToMirage<
EmberDataModelRegistry,
RelationshipsFor<EmberDataModelRegistry[K]>
>
: never;
/**
* Extract model attributes from the Ember Data model.
*/
type RelationshipsFor<M extends EmberDataModel> = Pick<
M,
FilterKeysByType<
M,
PromiseManyArray<EmberDataModel> | PromiseRecord<EmberDataModel>
>
>;
/**
* Maps Ember Data model types to Mirage model types.
* Ember Data relationships will be converted to Mirage relationships.
* All other attribute types will remain unchanged.
*/
type MapEmberDataRelationshipsToMirage<
EmberDataModelRegistry,
M extends Partial<EmberDataModel>
> = {
[K in keyof M]: MapEmberDataRelationshipToMirage<
EmberDataModelRegistry,
M[K]
>;
};
/**
* Converts an Ember Data model attribute's type to a Mirage model type.
* If T is a AsyncHasMany<M>,
* then it will be converted to HasMany<'m'>.
* If T is a AsyncBelongsTo<M>,
* then it will be converted to BelongsTo<'m'>.
* Otherwise, T will be never.
*/
type MapEmberDataRelationshipToMirage<
EmberDataModelRegistry,
T
> = T extends AsyncHasMany<infer Model>
? HasMany<ModelName<EmberDataModelRegistry, Model>>
: T extends AsyncBelongsTo<infer Model>
? BelongsTo<ModelName<EmberDataModelRegistry, Model>>
: never;
/**
* Gets the ModelName from the Ember Data Model Registry for the given model.
*/
type ModelName<EmberDataModelRegistry, Model> = FilterKeysByType<
EmberDataModelRegistry,
Model
> extends string
? FilterKeysByType<EmberDataModelRegistry, Model>
: never;
// eslint-disable-next-line ember/use-ember-data-rfc-395-imports
import ModelRegistry from 'ember-data/types/registries/model';
import * as Mirage from 'miragejs';
import { ConvertModelRegistry } from './registry-helper';
/**
* Convert Ember Data models to Mirage Model types so `server.create`, etc will
* know about model relationships. This is automagic assuming your model
* relationships are typed properly and the model is registered with the Ember
* Data Model Registry:
*
* @example
* ```ts
* import Model, { belongsTo, hasMany } from '@ember-data/model';
*
* class App extends Model {
* @belongsTo('user')
* declare owner: AsyncBelongsTo<User>;
*
* @hasMany('user')
* declare users: AsyncHasMany<User>;
* }
*
* declare module 'ember-data/types/registries/model' {
* export default interface ModelRegistry {
* app: App;
* }
* }
* ```
*/
type Models = ConvertModelRegistry<ModelRegistry>;
/**
* Add factories here so that `server.create`, etc will know about model
* attributes.
*
* See mirage/factories/types/attr.ts for more details.
*/
type Factories = {
user: typeof import('./factories/user').default;
};
export type Registry = Mirage.Registry<Models, Factories>;
import { module, test } from 'qunit';
import { visit } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { MirageTestContext } from 'ember-cli-mirage/test-support';
interface Context extends MirageTestContext {}
module('My Acceptance Test', function(hooks) {
setupApplicationTest(hooks);
test('visiting /', async function(this: Context, assert) {
let mirageUser = this.server.create('user');
await visit('/');
assert.dom('.user-name').hasText(mirageUser.fullName);
});
});
import { BelongsTo, HasMany, ModelDefinition } from 'miragejs/-types';
export { BelongsTo, HasMany, ModelDefinition };
import {
ActiveModelSerializer,
Collection,
Factory,
Instantiate,
JSONAPISerializer,
Model,
ModelInstance,
Response,
RestSerializer,
Serializer,
Server as MirageServer,
} from 'miragejs';
import { Registry } from '<app-name>/mirage/registry';
/**
* Result of `server.create('modelName', params);`
* (including undefined/null props)
*/
export type Init<K extends keyof Registry> = Instantiate<Registry, K>;
/**
* Params object for `server.create('modelName', params);`
*/
export type Data<K extends keyof Registry> = Partial<ModelInitializer<Init<K>>>;
/**
* Result of `server.create('modelName', params);`
* (with undefined/null props removed)
*/
export type Instantiated<I, D> = I &
{
[K in keyof I & keyof D]: Exclude<I[K], undefined | null>;
};
type ModelInitializer<Data> = {
[K in keyof Data]: Data[K] extends Collection<infer M>
? Collection<M> | M[]
: Data[K];
};
interface Server extends MirageServer<Registry> {
/**
* Creates a model of the given type.
*
* @param modelName The type of model to instantiate
* @param [t0] Optional trait name
* @param [t1] Optional trait name
* @param [t2] Optional trait name
* @param [data] Optional initial values for model attributes/relationships
*/
create<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
data?: Data<ModelName>
): Instantiated<I, D>;
create<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
data?: Data<ModelName>
): Instantiated<I, D>;
create<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
t1: string,
data?: Data<ModelName>
): Instantiated<I, D>;
create<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
t1: string,
t2: string,
data?: Data<ModelName>
): Instantiated<I, D>;
create<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
...args: unknown[]
): Instantiated<I, D>;
/**
* Builds a model of the given type.
*
* @param modelName The type of model to instantiate
* @param [t0] Optional trait name
* @param [t1] Optional trait name
* @param [t2] Optional trait name
* @param [data] Optional initial values for model attributes/relationships
*/
build<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
data?: Data<ModelName>
): Instantiated<I, D>;
build<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
data?: Data<ModelName>
): Instantiated<I, D>;
build<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
t1: string,
data?: Data<ModelName>
): Instantiated<I, D>;
build<
ModelName extends keyof Registry,
I extends Init<ModelName>,
D extends Data<ModelName>
>(
modelName: ModelName,
t0: string,
t1: string,
t2: string,
data?: Data<ModelName>
): Instantiated<I, D>;
}
export {
ActiveModelSerializer,
Collection,
Factory,
Instantiate,
JSONAPISerializer,
Model,
ModelInstance,
Response,
RestSerializer,
Serializer,
Server,
};
/**
* See https://www.ember-cli-mirage.com/docs/data-layer/factories#traits
*
* @param extension An extension of the factory to include when the trait name
* is used
*/
export function trait<T extends Record<string, unknown>>(
extension: T
): T & { __isTrait__: true };
/**
* See https://www.ember-cli-mirage.com/docs/data-layer/factories#the-association-helper
*
* @param args Optional arguments that match those that can be passed to
* `Server.create`.
*/
export function association<ModelName extends keyof Registry>(
...args: unknown[]
): Init<ModelName>;
import { Server } from 'ember-cli-mirage';
import { TestContext } from 'ember-test-helpers';
export function setupMirage(hooks: NestedHooks): void;
// Allows you to use `this.server` in Mirage tests.
export interface MirageTestContext extends TestContext {
server: Server;
}
@rreckonerr
Copy link

You'll have to pass the MirageTestContext manually, as mentioned in mirage:helpers.ts from this gist.

/**
 * Provides access to the Mirage server outside of test contexts (e.g. in a Factory).
 * If you are in a test, use MirageTestContext instead.
 */

Are you sure you're using the proper test context? E.g.

import { MirageTestContext, setupMirage } from 'ember-cli-mirage/test-support';
//  ...
 hooks.beforeEach(async function (this: MirageTestContext) {
 // ...
 test('it should be possible ...', async function (this: MirageTestContext, assert) {
 // ...

@RobbieTheWagner
Copy link

@rreckonerr Which issue were you referring to with your response? I definitely do pass it in.

import { MirageTestContext, setupMirage } from 'ember-cli-mirage/test-support';
//  ...
  let foo;
  hooks.beforeEach(async function (this: MirageTestContext) {
    foo = this.server.create('foo');
    const fooId = foo.id;
  }

If you try to do this it'll give you the errors I mentioned above about id types not matching and hasMany not matching the instantiated collection etc.

@rreckonerr
Copy link

This also seems to break this.set in tests, somehow making the tests think they don't have that on their context 🤔

I referred to this one above. Got it! Just so we're on the same page.

Not sure how to address the registry issue you've mentioned though. That's what I get in my case
image

@RobbieTheWagner
Copy link

@rreckonerr that's because you're setting it in the same spot. const foo = 'bar'; knows it is a string because it is set right there. If you define hotel as let hotel; above and then set it, it won't know the type.

@RobbieTheWagner
Copy link

Like we need a way to grab an Instantiated of a given type. I have tried stuff like ModelDefinitionFor<ModelRegistry, ModelName> and Registry[ModelName] and neither of them give you an Instantiated.

@rreckonerr
Copy link

You're right!

@RobbieTheWagner
Copy link

It seems like Init<ModelName> might work? Unsure if that is the correct thing to use or not.

@RobbieTheWagner
Copy link

Is there a way to get these types to work with createList?

@gitKrystan
Copy link
Author

I would think so. I haven't used these types in a while though.

@RobbieTheWagner
Copy link

I would think so. I haven't used these types in a while though.

Are you using something else instead?

@gitKrystan
Copy link
Author

gitKrystan commented Nov 7, 2023

The company I am at now uses lots of any for their Mirage types. Skylight still uses these, but I don't know how much the types have changed since I left. (probably not much)

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