In this section, we cover how to use TypeScript effectively with specific EmberData APIs (anything you'd find under the @ember-data
package namespace).
We do not cover general usage of EmberData; instead, we assume that as background knowledge. Please see the EmberData Guides and API docs!
Models
EmberData models are normal TypeScript classes, but with properties decorated to define how the model represents an API resource and relationships to other resources. The decorators the library supplies "just work" with TypeScript at runtime, but require type annotations to be useful with TypeScript. Additionally, you must register each model with the ModelRegistry
as shown in the examples below.
@attr
The type returned by the @attr
decorator is whatever Transform is applied via the invocation. See our overview of Transforms for more information.
If you supply no argument to @attr
, the value is passed through without transformation.
If you supply one of the built-in transforms, you will get back a corresponding type:
@attr('string')
→string
@attr('number')
→number
@attr('boolean')
→boolean
@attr('date')
→Date
If you supply a custom transform, you will get back the type returned by your transform.
So, for example, you might write a class like this:
import Model, { attr } from '@ember-data/model';
import CustomType from '../transforms/custom-transform';
export default class User extends Model {
@attr
declare name?: string;
@attr('number')
declare age: number;
@attr('boolean')
declare isAdmin: boolean;
@attr('custom-transform')
declare myCustomThing: CustomType;
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
user: User;
}
}
Type Safety for Model Attributes
Even more than with decorators in general, you should be careful when deciding whether to mark a property as optional ?
or definitely present (no annotation): EmberData will default to leaving a property empty if it is not supplied by the API or by a developer when creating it. That is: the default for EmberData corresponds to an optional field on the model.
The safest type you can write for an EmberData model, therefore, leaves every property optional: this is how models actually behave. If you choose to mark properties as definitely present by leaving off the ?
, you should take care to guarantee that this is a guarantee your API upholds, and that ever time you create a record from within the app, you uphold those guarantees.
One way to make this safer is to supply a default value using the defaultValue
on the options hash for the attribute:
import Model, { attr } from '@ember-data/model';
export default class User extends Model {
@attr
declare name?: string;
@attr('number', { defaultValue: 13 })
declare age: number;
@attr('boolean', { defaultValue: false })
declare isAdmin: boolean;
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
user: User;
}
}
Relationships
Relationships between models in EmberData rely on importing the related models, like import User from './user';
. This, naturally, can cause a recursive loop, as /app/models/post.ts
imports User
from /app/models/user.ts
, and /app/models/user.ts
imports Post
from /app/models/post.ts
. Recursive importing triggers an import/no-cycle
error from ESLint.
To avoid these errors, use type-only imports:
import type User from './user';
@belongsTo
The type returned by the @belongsTo
decorator depends on whether the relationship is { async: true }
(which it is by default).
- If the value is
true
, the type you should use isAsyncBelongsTo<Model>
, whereModel
is the type of the model you are creating a relationship to. - If the value is
false
, the type isModel
, whereModel
is the type of the model you are creating a relationship to.
So, for example, you might define a class like this:
import Model, { belongsTo, type AsyncBelongsTo } from '@ember-data/model';
import type User from './user';
import type Site from './site';
export default class Post extends Model {
@belongsTo('user')
declare user: AsyncBelongsTo<User>;
@belongsTo('site', { async: false })
declare site: Site;
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
post: Post;
}
}
These are type-safe to define as always present, that is to leave off the ?
optional marker:
- accessing an async relationship will always return an
AsyncBelongsTo<Model>
object, which itself may or may not ultimately resolve to a value—depending on the API response—but will always be present itself. - accessing a non-async relationship which is known to be associated but has not been loaded will trigger an error, so all access to the property will be safe if it resolves at all.
Note, however, that this type-safety is not a guarantee of there being no runtime error: you still need to uphold the contract for non-async relationships (that is: loading the data first, or side-loading it with the request) to avoid throwing an error!
@hasMany
The type returned by the @hasMany
decorator depends on whether the relationship is { async: true }
(which it is by default).
- If the value is
true
, the type you should use isAsyncHasMany<Model>
, whereModel
is the type of the model you are creating a relationship to. - If the value is
false
, the type isSyncHasMany<Model>
, whereModel
is the type of the model you are creating a relationship to.
So, for example, you might define a class like this:
import Model, {
hasMany,
type AsyncHasMany,
type SyncHasMany,
} from '@ember-data/model';
import type Comment from './comment';
import type User from './user';
export default class Thread extends Model {
@hasMany('comment')
declare comments: AsyncHasMany<Comment>;
@hasMany('user', { async: false })
declare participants: SyncHasMany<User>;
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
thread: Thread;
}
}
The same basic rules about the safety of these lookups as with @belongsTo
apply to these types. The difference is just that in @hasMany
the resulting types are arrays rather than single objects.
Transforms
In EmberData, @attr
defines an attribute on a Model. By default, attributes are passed through as-is, however you can specify an optional type to have the value automatically transformed. EmberData ships with four basic transform types: string
, number
, boolean
and date
.
You can define your own transforms by sub-classing Transform. EmberData transforms are normal TypeScript classes. The return type of deserialize
method becomes type of the model class property.
You may define your own transforms in TypeScript like so:
import Transform from '@ember-data/serializer/transform';
export type CoordinatePoint = {
x: number;
y: number;
};
export default class CoordinatePointTransform extends Transform {
deserialize(serialized): CoordinatePoint {
return { x: value[0], y: value[1] };
}
serialize(value): number {
return [value.x, value.y];
}
}
declare module 'ember-data/types/registries/transform' {
export default interface TransformRegistry {
'coordinate-point': CoordinatePointTransform;
}
}
import Model, { attr } from '@ember-data/model';
import { CoordinatePoint } from 'my-app/transforms/coordinate-point';
export default class Cursor extends Model {
@attr('coordinate-point') declare position: CoordinatePoint;
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
cursor: Cursor;
}
}
Note that you should declare your own transform under TransformRegistry
to make @attr
to work with your transform.
Serializers and Adapters
EmberData serializers and adapters are normal TypeScript classes. The only related gotcha is that you must register them with a declaration:
import Serializer from '@ember-data/serializer';
export default class UserMeta extends Serializer {}
declare module 'ember-data/types/registries/serializer' {
export default interface SerializerRegistry {
'user-meta': UserMeta;
}
}
import Adapter from '@ember-data/adapter';
export default class User extends Adapter {}
declare module 'ember-data/types/registries/adapter' {
export default interface AdapterRegistry {
user: User;
}
}
EmberData Registries
We use registry approach for EmberData type lookups with string keys. As a result, once you add the module and interface definitions for each model, transform, serializer, and adapter in your app, you will automatically get type-checking and autocompletion and the correct return types for functions like findRecord
, queryRecord
, adapterFor
, serializerFor
, etc. No need to try to write out those types; just write your EmberData calls like normal and everything should just work. That is, writing this.store.findRecord('user', 1)
will give you back a Promise<User | undefined>
.