Only this pageAll pages
Powered by GitBook
1 of 29

ember-cli-typescript

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Installation

You can simply ember install the dependency like normal:

ember install ember-cli-typescript@latest

All dependencies will be added to your package.json, and you're ready to roll!

If you're upgrading from a previous release, see (./upgrade-notes.md).

Installing ember-cli-typescript modifies your project in two ways:

  • installing a number of other packages to make TypeScript work in your app or addon

  • generating a number of files in your project

Other packages this addon installs

We install all of the following packages at their current "latest" value, :

  • typescript

  • ember-cli-typescript-blueprints

  • @types/ember

  • @types/ember-data

  • @types/ember__* – @types/ember__object for @ember/object etc.

  • @types/ember-data__* – @types/ember-data__model for @ember-data/model etc.

  • @types/rsvp

Files this addon generates

We also add the following files to your project:

  • tsconfig.json

  • types/<app name>/index.d.ts – the location for any global type declarations you need to write for you own application; see Using TS Effectively: Global types for your package for information on its default contents and how to use it effectively

  • app/config/environment.d.ts – a basic set of types defined for the contents of the config/environment.js file in your app; see Environment and configuration typings for details

ember-cli-typescript


TypeScript docs have moved! 🎉

This documentation is now hosted on the ember guides website here: Using TypeScript with Ember


ember-cli-typescript

This guide is designed to help you get up and running with TypeScript in an Ember app.

This is not an introduction to TypeScript or Ember. Throughout this guide, we’ll link back to the TypeScript docs and the Ember Guides when there are specific concepts that we will not explain here but which are important for understanding what we’re covering!

To get started, check out the instructions in Getting Started: Installation

  • If you're totally new to using TypeScript with Ember, start with TypeScript and Ember.

  • Once you have a good handle on the basics, you can dive into the guides to working with the APIs specific to Ember and Ember Data.

  • If you're working with legacy (pre-Octane) Ember and TypeScript together, you should read the Legacy Guide.

  • Looking for type-checking in Glimmer templates? Check out Glint.

Why TypeScript?

What is TypeScript, and why should you adopt it?

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. —typescriptlang.org

TypeScript lets you build ambitious web applications with confidence—so it’s a perfect fit for Ember apps!

  • Get rid of undefined is not a function and null is not an object once and for all.

  • Enjoy API docs… that are always up-to-date.

  • Experience better developer productivity through top-notch editor support, including incredible autocomplete, guided refactorings, automatic imports, and more.

Troubleshooting

Stuck with something? Hopefully one of the documents below can help. If not, file an issue on GitHub and we'll try to help you get it sorted (and it may end up in here).

Outline

  • Conflicting Type Dependencies

Current limitations

While TS already works nicely for many things in Ember, there are a number of corners where it won't help you out. Some of them are just a matter of further work on updating the ; others are a matter of further support landing in TypeScript itself, or changes to Ember's object model.

Some imports don't resolve

You'll frequently see errors for imports which TypeScript doesn't know how to resolve. These won't stop the build from working; they just mean TypeScript doesn't know where to find those.

Writing these missing type definitions is a great way to pitch in! Jump in #topic-typescript on the and we'll be happy to help you.

Templates

Templates are currently totally non-type-checked. This means that you lose any safety when moving into a template context, even if using a Glimmer Component in Ember Octane.

Addons need to import templates from the associated .hbs file to bind to the layout of any components they export. The TypeScript compiler will report that it cannot resolve the module, since it does not know how to resolve files ending in .hbs. To resolve this, you can provide this set of definitions to my-addon/types/global.d.ts, which will allow the import to succeed:

Invoking actions

TypeScript won't detect a mismatch between this action and the corresponding call in the template:

Likewise, it won't notice a problem when you use the send method:

Cookbook

This “cookbook” section contains recipes for various scenarios you may encounter while working on your app or addon.

Have an idea for an item that should fit here? We'd love to help you help us make this experience more awesome for everyone.

Contents

declare module '\*/template' {
  import { TemplateFactory } from 'ember-cli-htmlbars';
  const template: TemplateFactory; export default template;
}


declare module 'app/templates/\*' {
  import { TemplateFactory } from 'ember-cli-htmlbars';
  const template: TemplateFactory; export default template;
}

declare module 'addon/templates/\*' {
  import { TemplateFactory } from 'ember-cli-htmlbars';
  const template: TemplateFactory; export default template;
}
import Component from '@ember/component';
import { action } from '@ember/object';

export default class MyGame extends Component {
  @action turnWheel(degrees: number) {
    // ...
  }
}
<button {{on "click" (fn this.turnWheel "potato")}}>
Click Me
</button>
// TypeScript compiler won't detect this type mismatch
this.send\('turnWheel', 'ALSO-NOT-A-NUMBER'\);
existing typings
Ember Community Discord server
Open an issue for it!
Working with route models

Configuration

tsconfig.json

We generate a good default tsconfig.json, which will usually make everything Just Work™. In general, you may customize your TypeScript build process as usual using the tsconfig.json file.

However, there are a few things worth noting if you're already familiar with TypeScript and looking to make further or more advanced customizations (but most users can just ignore this section!):

  1. The generated tsconfig file does not set "outDir" and sets "noEmit" to true. The default configuration we generate allows you to run editors which use the compiler without creating extraneous .js files throughout your codebase, leaving the compilation to ember-cli-typescript to manage.

    You can still customize those properties in tsconfig.json if your use case requires it, however. For example, to see the output of the compilation in a separate folder you are welcome to set "outDir" to some path and set "noEmit" to false. Then tools which use the TypeScript compiler (e.g. the watcher tooling in JetBrains IDEs) will generate files at that location, while the Ember.js/Broccoli pipeline will continue to use its own temp folder.

  2. Closely related to the previous point: any changes you do make to outDir won't have any effect on how Ember builds your application—we run the entire build pipeline through Babel's TypeScript support instead of through the TypeScript compiler.

  3. Since your application is built by Babel, and only type-checked by TypeScript, we set the target key in tsconfig.json to the current version of the ECMAScript standard so that type-checking uses the latest and greatest from the JavaScript standard library. The Babel configuration in your app's config/targets.js and any included polyfills will determine the final build output.

  4. If you make changes to the paths included in or excluded from the build via your tsconfig.json (using the "include", "exclude", or "files" keys), you will need to restart the server to take the changes into account: ember-cli-typescript does not currently watch the tsconfig.json file. For more details, see the TypeScript reference materials for tsconfig.json.

Enabling Sourcemaps

To enable TypeScript sourcemaps, you'll need to add the corresponding configuration for Babel to your ember-cli-build.js file:

const app = new EmberApp(defaults, {
  babel: {
    sourceMaps: 'inline',
  },
});

(Note that this will noticeably slow down your app rebuilds.)

If you are using Embroider, you might need to include devtool in your webpack configuration:

return require('@embroider/compat').compatBuild(app, Webpack, {
  packagerOptions: {
    webpackConfig: { 
      devtool: 'source-map'
    }
  }
}

If you're updating from an older version of the addon, you may also need to update your tsconfig.json. (Current versions generate the correct config at installation.) Either run ember generate ember-cli-typescript or verify you have the same sourcemap settings in your tscsonfig.json that appear in the blueprint.

Controllers

Like routes, controllers are just normal classes with a few special Ember lifecycle hooks and properties available.

The main thing you need to be aware of is special handling around query params. In order to provide type safety for query param configuration, Ember's types specify that when defining a query param's type attribute, you must supply one of the allowed types: 'boolean', 'number', 'array', or 'string' (the default). However, if you supply these types as you would in JS, like this:

import Controller from "@ember/controller";

export default class HeyoController extends Controller {
  queryParams = [
    {
      category: { type: "array" },
    },
  ];
}

Then you will see a type error like this:

Property 'queryParams' in type 'HeyoController' is not assignable to the same property in base type 'Controller'.
  Type '{ category: { type: string; }; }[]' is not assignable to type '(string | Record<string, string | QueryParamConfig | undefined>)[]'.
    Type '{ category: { type: string; }; }' is not assignable to type 'string | Record<string, string | QueryParamConfig | undefined>'.
      Type '{ category: { type: string; }; }' is not assignable to type 'Record<string, string | QueryParamConfig | undefined>'.
        Property 'category' is incompatible with index signature.
          Type '{ type: string; }' is not assignable to type 'string | QueryParamConfig | undefined'.
            Type '{ type: string; }' is not assignable to type 'QueryParamConfig'.
              Types of property 'type' are incompatible.
                Type 'string' is not assignable to type '"string" | "number" | "boolean" | "array" | undefined'.ts(2416)

This is because TS currently infers the type of type: "array" as type: string. You can work around this by supplying as const after the declaration:

import Controller from "@ember/controller";

export default class HeyoController extends Controller {
  queryParams = [
    {
-     category: { type: "array" },
+     category: { type: "array" as const },
    },
  ];
}

Now it will type-check.

Decorators

Ember makes heavy use of decorators, and TypeScript does not currently support deriving type information from decorators.

As a result, there are three important points that apply to all decorator usage in Ember:

  1. Whenever using a decorator to declare a class field the framework sets up for you, you should mark it with declare. That includes all service and controller injections as well as all Ember Data attributes and relationships.

    Normally, TypeScript determines whether a property is definitely not null or undefined by checking what you do in the constructor. In the case of service injections, controller injections, or Ember Data model decorations, though, TypeScript does not have visibility into how instances of the class are initialized. The declare annotation informs TypeScript that a declaration is defined somewhere else, outside its scope.

  2. For Ember Data Models, you will need to use the optional ? operator on field declarations if the field is optional (?). See the Ember Data section of the guide for more details!

  3. You are responsible to write the type correctly. TypeScript does not currently use decorator information at all in its type information. If you write @service foo or even @service('foo') foo, Ember knows that this resolves at runtime to the service Foo, but TypeScript does not and—for now—cannot.

    This means that you are responsible to provide this type information, and that you are responsible to make sure that the information remains correct and up to date

For examples, see the detailed discussions of the two main places decorators are used in the framework:

  • Services

  • Ember Data Models

TypeScript and Ember

This guide covers the common details and "gotchas" of using TypeScript with Ember. Note that we do not cover the use of TypeScript or Ember in general—for those, you should refer to the corresponding documentation:

  • TypeScript docs

  • TypeScript Deep Dive

  • Ember docs

Outline

  • Using TypeScript With Ember Effectively

  • Decorators

  • Current limitations

  • Building Addons in TypeScript

  • Understanding the @types Package Names

Working With Ember Data

In this section, we cover how to use TypeScript effectively with specific Ember Data APIs (anything you'd find under the @ember-data package namespace).

We do not cover general usage of Ember Data; instead, we assume that as background knowledge. Please see the Ember Data Guides and API docs!

Working With Ember

In this section, we cover how to use TypeScript effectively with specific Ember APIs (anything you'd find under the @ember package namespace).

We do not cover general usage of Ember; instead, we assume that as background knowledge. Please see the Ember Guides and API docs!

Outline

  • Components

  • Services

  • Routes

  • Controllers

  • Helpers

  • Testing

Working With Ember Classic

We emphasize the happy path of working with Ember in the Octane Edition. However, there are times you’ll need to understand these details:

  1. Most existing applications make heavy use of the pre-Octane (“legacy”) Ember programming model, and we support that model—with caveats.

  2. Several parts of Ember Octane (specifically: routes, controllers, services, and class-based helpers) continue to use these concepts under the hood, and our types support that—so understanding them may be important at times.

The rest of this guide is dedicated to helping you understand how ember-cli-typescript and the classic Ember system interact.

EmberComponent

Computed Properties

There are two variants of Ember’s computed properties you may encounter:

  • the decorator form used with native (ES6) classes

  • the callback form used with classic classes (based on EmberObject)

Decorator form

Note that it is impossible for @computed to know whether the keys you pass to it are allowed or not. Migrating to Octane eliminates this issue, since you mark reactive root state with @tracked and leave getters undecorated, rather than vice versa.

Callback form

Computed properties in the classic object model take a callback instead:

This definition will not type-check, however. You will need to explicitly write out a this type for computed property callbacks for get and set to type-check correctly:

Note that this does not always work: you may get warnings from TypeScript about the item being defined in terms of itself.

Accordingly, we strongly recommend migrating classic classes to ES native classes before adding TypeScript!

Routes

Working with Routes is in general just working normal TypeScript classes. Ember's types supply the definitions for the various lifecycle events available within route subclasses, which will provide autocomplete and type-checking along the way in general.

However, there is one thing to watch out for: the types of the arguments passed to methods will not autocomplete as you may expect. This is because in general a subclass may override a superclass method as long as it calls its superclass's method correctly. This is very bad practice, but it is legal JavaScript! This is never a concern for lifecycle hooks in Ember, because they are called by the framework itself. However, TypeScript does not and cannot know that, so we have to provide the types directly.

Accordingly, and because the Transition type is not currently exported as a public type, you may find it convenient to define it using TypeScript's ReturnType utility type, which does exactly what it sounds like and gives us a local type which is the type returned by some function. The RouterService.transitionTo returns a Transition, so we can rely on that as stable public API to define Transition locally ourselves:

This inconsistency will be solved in the future. For now, this workaround gets the job done, and also shows the way to using this information to provide the type of the route's model to other consumers: see for details!

The Resolved<T> utility type takes in any type, and if the type is a Promise it transforms the type into whatever the Promise resolves to; otherwise it just returns the same type. (If you’re using TypeScript 4.5 or later, you can use the built-in Awaited<T> type, which does the same thing but more robustly: it also handles nested promises.) As we saw above, ReturnType gets us the return type of the function. So our final MyRouteModel type takes the return type from our model hook, and uses the Resolved type to get the type the promise will resolve to—that is, exactly the type we will have available as @model in the template and as this.model on a controller.

This in turn allows us to use the route class to define the type of the model on an associated controller.

Notice here that the model is declared as optional. That’s intentional: the model for a given controller is not set when the controller is constructed (that actually happens either when the page corresponding to the controller is created or the first time a <LinkTo> which links to that page is rendered). Instead, the model is set on the controller when the corresponding route is successfully entered, via its setupController hook.

Transforms

In Ember Data, attr defines an attribute on a . By default, attributes are passed through as-is, however you can specify an optional type to have the value automatically transformed. Ember Data ships with four basic transform types: string, number, boolean and date.

You can define your own transforms by subclassing . Ember Data 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:

Note that you should declare your own transform under TransformRegistry to make attr to work with your transform.

Mixins

Mixins are fundamentally hostile to robust typing with TypeScript. While you can supply types for them, you will regularly run into problems with self-referentiality in defining properties within the mixins.

As a stopgap, you can refer to the type of a mixin using the typeof operator.

In general, however, prefer to use one of the following four strategies for migrating away from mixins before attempting to convert code which relies on them to TypeScript:

  1. For functionality which encapsulates DOM modification, rewrite as a custom modifier using .

  2. If the mixin is a way of supplying shared behavior (not data), extract it to utility functions, usually just living in module scope and imported and exported as needed.

  3. If the mixin is a way of supplying non-shared state which follows the lifecycle of a given object, replace it with a utility class instantiated in the owning class's constructor (or init for legacy classes).

  4. If the mixin is a way of supplying long-lived, shared state, replace it with a service and inject it where it was used before. This pattern is uncommon, but sometimes appears when mixing functionality into multiple controllers or services.

You can also use inheritance and class decorators to accomplish some of the same semantics as mixins classically supplied. However, these patterns are more fragile and therefore not recommended.

import Component from '@ember/component';
import { computed } from '@ember/object/computed';

export default class UserProfile extends Compoennt {
  name = 'Chris';
  age = 33;

  @computed('name', 'age')
  get bio() {
    return `${this.name} is `${this.age}` years old!`;
  }
}
import Component from '@ember/component';
import { computed } from '@ember/object/computed';

const UserProfile = Component.extend({
  name: 'Chris',
  age: 32,

  bio: computed('name', 'age', function() {
    return `${this.get('name')} is `${this.get('age')}` years old!`;
  }),
})

export default UserProfile;
import Component from '@ember/component';
import { computed } from '@ember/object/computed';

const UserProfile = Component.extend({
  name: 'Chris',
  age: 32,

  bio: computed('name', 'age', function(this: UserProfile) {
    //                                  ^---------------^
    // `this` tells TS to use `UserProfile` for `get` and `set` lookups;
    // otherwise `this.get` below would not know the types of `'name'` or
    // `'age'` or even be able to suggest them for autocompletion.
    return `${this.get('name')} is `${this.get('age')}` years old!`;
  }),
})

export default UserProfile;
import Route from '@ember/routing/route';
import type RouterService from '@ember/routing/router-service';
type Transition = ReturnType<RouterService['transitionTo']>;

export default class MyRoute extends Route {
  beforeModel(transition: Transition) {
    // ...
  }
}
import Route from '@ember/routing/route';

type Resolved<P> = P extends Promise<infer T> ? T : P;

export type MyRouteModel = Resolved<ReturnType<MyRoute['model']>>;

export default class MyRoute extends Route {
  model() {
    // ...
  }
}
import Controller from '@ember/controller';
import type { MyRouteModel } from '../routes/my-route';

export default class MyController extends Controller {
  declare model?: MyRouteModel;

  // ...
}
Working with Route Models
# app/transforms/coordinate-point.ts
import Transform from '@ember-data/serializer/transform';

declare module 'ember-data/types/registries/transform' {
  export default interface TransformRegistry {
    'coordinate-point': CoordinatePointTransform;
  }
}

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];
  }
}

# app/models/cursor.ts
import Model, { attr } from '@ember-data/model';
import { CoordinatePoint } from 'agwa-data/transforms/coordinate-point';

declare module 'ember-data/types/registries/model' {
  export default interface ModelRegistry {
    cursor: Cursor;
  }
}

export default class Cursor extends Model {
  @attr('coordinate-point') declare position: CoordinatePoint;
}
Model
Transform
ember-modifier

Working with route models

We often use routes’ models throughout our application, since they’re a core ingredient of our application’s data. As such, we want to make sure that we have good types for them!

We can start by defining some type utilities to let us get the resolved value returned by a route’s model hook:

import Route from '@ember/routing/route';

/**
  Get the resolved type of an item.

  - If the item is a promise, the result will be the resolved value type
  - If the item is not a promise, the result will just be the type of the item
 */
export type Resolved<P> = P extends Promise<infer T> ? T : P;

/** Get the resolved model value from a route. */
export type ModelFrom<R extends Route> = Resolved<ReturnType<R['model']>>;

How that works:

  • Resolved<P> says "if this is a promise, the type here is whatever the promise resolves to; otherwise, it's just the value"

  • ReturnType<T> gets the return value of a given function

  • R['model'] (where R has to be Route itself or a subclass) uses TS's mapped types to say "the property named model on R

Putting those all together, ModelFrom<Route> ends up giving you the resolved value returned from the model hook for a given route:

type MyRouteModel = ModelFrom<MyRoute>;

model on the controller

We can use this functionality to guarantee that the model on a Controller is always exactly the type returned by Route::model by writing something like this:

import Controller from '@ember/controller';
import MyRoute from '../routes/my-route';
import { ModelFrom } from '../lib/type-utils';

export default class ControllerWithModel extends Controller {
  declare model: ModelFrom<MyRoute>;
}

Now, our controller’s model property will always stay in sync with the corresponding route’s model hook.

Note: this only works if you do not mutate the model in either the afterModel or setupController hooks on the route! That's generally considered to be a bad practice anyway. If you do change the type there, you'll need to define the type in some other way and make sure your route's model is defined another way.

Services

Ember Services are global singleton classes that can be made available to different parts of an Ember application via dependency injection. Due to their global, shared nature, writing services in TypeScript gives you a build-time-enforcable API for some of the most central parts of your application.

If you are not familiar with Services in Ember, first make sure you have read and understood the Ember Guide on Services!

A basic service

Let's take this example from the Ember Guide:

import { A } from '@ember/array';
import Service from '@ember/service';

export default class ShoppingCartService extends Service {
  items = A([]);

  add(item) {
    this.items.pushObject(item);
  }

  remove(item) {
    this.items.removeObject(item);
  }

  empty() {
    this.items.clear();
  }
}

Just making this a TypeScript file gives us some type safety without having to add any additional type information. We'll see this when we use the service elsewhere in the application.

When working in Octane, you're better off using a TrackedArray from tracked-built-ins instead of the classic EmberArray:

import { TrackedArray } from 'tracked-built-ins';
import Service from '@ember/service';

export default class ShoppingCartService extends Service {
  items = new TrackedArray();

  add(item) {
    this.items.push(item);
  }

  remove(item) {
    this.items.splice(1, this.items.findIndex((i) => i === item));
  }

  empty() {
    this.items.clear();
  }
}

Notice that here we are using only built-in array operations, not Ember's custom array methods.

Using services

You can use a service in any container-resolved object such as a component or another service. Services are injected into these objects by decorating a property with the inject decorator. Because decorators can't affect the type of the property they decorate, we must manually type the property. Also, we must use declare modifier to tell the TypeScript compiler to trust that this property will be set up by something outside this component—namely, the decorator.

Here's an example of using the ShoppingCartService we defined above in a component:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

import ShoppingCartService from 'my-app/services/shopping-cart';

export default class CartContentsComponent extends Component {
  @service declare shoppingCart: ShoppingCartService;

  @action
  remove(item) {
    this.shoppingCart.remove(item);
  }
}

Any attempt to access a property or method not defined on the service will fail type-checking:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

import ShoppingCartService from 'my-app/services/shopping-cart';

export default class CartContentsComponent extends Component {
  @service declare shoppingCart: ShoppingCartService;

  @action
  remove(item) {
    // Error: Property 'saveForLater' does not exist on type 'ShoppingCartService'.
    this.shoppingCart.saveForLater(item);
  }
}

Services can also be loaded from the dependency injection container manually:

import Component from '@glimmer/component';
import { getOwner } from '@ember/owner';
import { action } from '@ember/object';

import ShoppingCartService from 'my-app/services/shopping-cart';

export default class CartContentsComponent extends Component {
  get cart() {
    return getOwner(this)?.lookup('service:shopping-cart') as ShoppingCartService;
  }

  @action
  remove(item) {
    this.cart.remove(item);
  }
}

Here we need to cast the lookup result to ShoppingCartService in order to get any type-safety because the lookup return type is any (see caution below).

This type-cast provides no guarantees that what is returned by the lookup is actually the service you are expecting. Because TypeScript cannot resolve the lookup micro-syntax (service:<name>) to the service class, a typo would result in returning something other than the specified type. It only gurantees that if the expected service is returned that you are using it correctly.

There is a merged (but not yet implemented) RFC which improves this design and makes it straightforward to type-check. Additionally, TypeScript 4.1's introduction of template types may allow us to supply types that work with the microsyntax.

For now, however, remember that the cast is unsafe!

EmberObject

When working with the legacy Ember object model, EmberObject, there are a number of caveats and limitations you need to be aware of. For today, these caveats and limitations apply to any classes which extend directly from EmberObject, or which extend classes which themselves extend EmberObject:

  • Component – meaning classic Ember components, which imported from @ember/component, not Glimmer components which are imported from @glimmer/component and do not extend the EmberObject base class.

  • Controller

  • Helper – note that this applies only to the class form. Function-based helpers do not involve the EmberObject base class.

  • Route

  • Router

  • Service

  • Ember Data’s Model class

Additionally, Ember’s mixin system is deeply linked to the semantics and implementation details of EmberObject, and it has the most caveats and limitations.

In the future, some of these may be able to drop their EmberObject base class dependency, but that will not happen till at least the next major version of Ember, and these guides will be updated when that happens.

Mixins and classic class syntax

The Ember mixin system is the legacy Ember construct TypeScript supports least well, as described in . While this may not be intuitively obvious, the classic class syntax simply is the mixin system. Every classic class creation is a case of mixing together multiple objects to create a new base class with a shared prototype. The result is that any time you see the classic .extend({ ... }) syntax, regardless of whether there is a named mixin involved, you are dealing with Ember's legacy mixin system. This in turn means that you are dealing with the parts of Ember which TypeScript is least able to handle well.

While we describe here how to use types with classic (mixin-based) classes insofar as they do work, there are many failure modes. As a result, we strongly recommend moving away from both classic classes and mixins, and as quickly as possible. This is the direction the Ember ecosystem as a whole is moving, but it is especially important for TypeScript users.

The includes guides for migrating , along with for dealing with specific kinds of mixins in your codebase.

Failure modes

You often need to define this in actions hashes, computed properties, etc. That in turn often leads to problems with self-referential this: TypeScript simply cannot figure out how to stop recursing through the definitions of the type.

Additionally, even when you get past the endlessly-recursive type definition problems, when enough mixins are resolved TypeScript will occasionally just give up because it cannot resolve the property or method you're interested in across the many shared base classes.

Finally, when you have "zebra-striping" of your classes between classic classes and native classes, your types will often stop resolving.

Native classes

EmberObject

In general, we recommend (following the Ember Octane guides) that any class which extends directly from the EmberObject base class eliminate any use of EmberObject-specific API and convert to standalone class, with no base class at all. You can follow the workflow to eliminate the base class—switching from init to constructor, getting rid of uses of methods like this.set and this.get in favor of using standalone set and get, and so on.

EmberObject-descended classes

The framework base classes which depend on EmberObject cannot follow the exact same path. However, as long as you are using native class syntax, all of these (Component, Controller, Helper, etc.) work nicely and safely with TypeScript. In each of these cases, the same caveats apply as with EmberObject itself, and you should follow the workflow with them as well if you are converting an existing app or addon. However, because these base classes themselves descend from EmberObject, you will not be able to remove the base classes as you can with your own classes which descend directly from EmberObject. Instead, you will continue to extend from the Ember base classes:

import Component from '@ember/component';
export default class Profile extends Component {}
import Controller from '@ember/controller';
export default class IndexController extends Controller {}
import Helper from '@ember/component/helper';
export default class Localize extends Helper {}
import Route from '@ember/routing/route';
export default class ApplicationRoute extends Route {}
import EmberRouter from '@ember/routing/router'
export default class AppRouter extends EmberRouter {}
import Service from '@ember/service';
export default class Session extends Service {}
import Model from '@ember-data/model';
export default class User extends Model {}
Mixins
Ember Atlas
from classic classes to native classes
a variety of patterns
ember-classic-decorator
ember-classic-decorator

Building Addons in TypeScript

Building addons in TypeScript offers many of the same benefits as building apps that way: it puts an extra tool at your disposal to help document your code and ensure its correctness. For addons, though, there's one additional bonus: publishing type information for your addons enables autocomplete and inline documentation for your consumers, even if they're not using TypeScript themselves.

Key Differences from Apps

To process .ts files, ember-cli-typescript tells Ember CLI to register a set of Babel plugins so that Babel knows how to strip away TypeScript-specific syntax. This means that ember-cli-typescript operates according to the same set of rules as other preprocessors when used by other addons.

  • Like other addons that preprocess source files, ember-cli-typescript must be in your addon's dependencies, not devDependencies.

  • Because addons have no control over how files in app/ are transpiled, you cannot have .ts files in your addon's app/ folder.

Publishing

When you publish an addon written in TypeScript, the .ts files will be consumed and transpiled by Babel as part of building the host application the same way .js files are, in order to meet the requirements of the application's config/targets.js. This means that no special steps are required for your source code to be consumed by users of your addon.

Even though you publish the source .ts files, though, by default you consumers who also use TypeScript won't be able to benefit from those types, because the TS compiler isn't aware of how ember-cli resolves import paths for addon files. For instance, if you write import { foo } from 'my-addon/bar';, the typechecker has no way to know that the actual file on disk for that import path is at my-addon/addon/bar.ts.

In order for your addon's users to benefit from type information from your addon, you need to put .d.ts declaration files at the location on disk where the compiler expects to find them. This addon provides two commands to help with that: ember ts:precompile and ember ts:clean. The default ember-cli-typescript blueprint will configure your package.json to run these commands in the prepack and postpack phases respectively, but you can also run them by hand to verify that the output looks as you expect.

The ts:precompile command will populate the overall structure of your package with .d.ts files laid out to match their import paths. For example, addon/index.ts would produce an index.d.ts file in the root of your package.

The ts:clean command will remove the generated .d.ts files, leaving your working directory back in a pristine state.

The TypeScript compiler has very particular rules when generating declaration files to avoid letting private types leak out unintentionally. You may find it useful to run ember ts:precompile yourself as you're getting a feel for these rules to ensure everything will go smoothly when you publish.

Linking Addons

Often when developing an addon, it can be useful to run that addon in the context of some other host app so you can make sure it will integrate the way you expect, e.g. using yarn link or npm link.

When you do this for a TypeScript addon, the source files will be picked up in the host app build and everything will execute at runtime as you'd expect. If the host app is also using TypeScript, though, it won't be able to resolve imports from your addon by default, for the reasons outlined above in the Publishing section.

You could run ember ts:precompile in your addon any time you change a file, but for development a simpler option is to temporarily update the paths configuration in the host application so that it knows how to resolve types from your linked addon.

Add entries for <addon-name> and <addon-name>/* in your tsconfig.json like so:

compilerOptions: {
  // ...other options
  paths: {
    // ...other paths, e.g. for your app/ and tests/ trees
    // resolve: import x from 'my-addon';
    "my-addon": [
      "node_modules/my-addon/addon"
    ],
    // resolve: import y from 'my-addon/utils/y';
    "my-addon/*": [
      "node_modules/my-addon/addon/*"
    ]
  }
}

In-Repo Addons

In-repo addons work in much the same way as linked ones. Their .ts files are managed automatically by ember-cli-typescript in their dependencies, and you can ensure imports resolve correctly from the host by adding entries in paths in the base tsconfig.json file.

compilerOptions: {
  // ...other options
  paths: {
    // ...other paths, e.g. for your tests/ tree
    "my-app": [
      "app/*",
      // add addon app directory that will be merged with the host application
      "lib/my-addon/app/*"
    ],
    // resolve: import x from 'my-addon';
    "my-addon": [
      "lib/my-addon/addon"
    ],
    // resolve: import y from 'my-addon/utils/y';
    "my-addon/*": [
      "lib/my-addon/addon/*"
    ]
  }
}

One difference as compared to regular published addons: you know whether or not the host app is using ember-cli-typescript, and if it is, you can safely put .ts files in an in-repo addon's app/ folder.

Upgrading from 1.x

There are a number of important changes between ember-cli-typescript v1 and v2, which mean the upgrade process is straightforward but specific:

  1. Update ember-cli-babel. Fix any problems introduced during the upgrade.

  2. Update ember-decorators. Fix any problems introduced during the upgrade.

  3. Update ember-cli-typescript. Follow the detailed upgrade guide below to fix discrepancies between Babel and TypeScript's compiled output.

If you deviate from this order, you are likely to have a much more difficult time upgrading!

Update ember-cli-babel

ember-cli-typescript requires ember-cli-babel at version 7.1.0 or above, which requires ember-cli 2.13 or above. It also requires @babel/core 7.2.0 or higher.

The recommended approach here is to deduplicate existing installations of the dependency, remove and reinstall ember-cli-babel to make sure that all its transitive dependencies are updated to the latest possible, and then to deduplicate again.

If using yarn:

npx yarn-deduplicate
yarn remove ember-cli-babel
yarn add --dev ember-cli-babel
npx yarn-deduplicate

If using npm:

npm dedupe
npm uninstall ember-cli-babel
npm install --save-dev ember-cli-babel
npm dedupe

Note: If you are also using ember-decorators—and specifically the babel-transform that gets added with it—you will need update @ember-decorators/babel-transforms as well (anything over 3.1.0 should work):

ember install ember-decorators@^3.1.0 @ember-decorators/babel-transforms@^3.1.0

Update ember-decorators

If you're on a version of Ember before 3.10, follow the same process of deduplication, reinstallation, and re-deduplication as described for ember-cli-babel above for ember-decorators. This will get you the latest version of ember-decorators and, importantly, its @ember-decorators/babel-transforms dependency.

Update ember-cli-typescript

Now you can simply ember install the dependency like normal:

ember install ember-cli-typescript@latest

Note: To work properly, starting from v2, ember-cli-typescript must be declared as a dependency, not a devDependency for addons. With ember install this migration would be automatically handled for you.

If you choose to make the upgrade manually with yarn or npm, here are the steps you need to follow:

  1. Remove ember-cli-typescript from your devDependencies.

    With yarn:

     yarn remove ember-cli-typescript

    With npm:

     npm uninstall ember-cli-typescript
  2. Install the latest of ember-cli-typescript as a dependency:

    With yarn:

     yarn add ember-cli-typescript@latest

    With npm:

     npm install --save ember-cli-typescript@latest
  3. Run ember generate:

     ember generate ember-cli-typescript

Account for addon build pipeline changes

Since we now integrate in a more traditional way into Ember CLI's build pipeline, there are two changes required for addons using TypeScript.

  • Addons can no longer use .ts in app, because an addon's app directory gets merged with and uses the host's (i.e. the other addon or app's) preprocessors, and we cannot guarantee the host has TS support. Note that .ts will continue to work for in-repo addons because the app build works with the host's (i.e. the app's, not the addon's) preprocessors.

  • Similarly, apps must use .js to override addon defaults in app, since the different file extension means apps no longer consistently "win" over addon versions (a limitation of how Babel + app merging interact).

Account for TS → Babel issues

ember-cli-typescript v2 uses Babel to compile your code, and the TypeScript compiler only to check your code. This makes for much faster builds, and eliminates the differences between Babel and TypeScript in the build output that could cause problems in v1. However, because of those differences, you’ll need to make a few changes in the process of upgrading.

Any place where a type annotation overrides a getter

  • Fields like element, disabled, etc. as annotated defined on a subclass of Component and (correctly) not initialized to anything, e.g.:

      import Component from '@ember/component';
    
      export default class Person extends Component {
        element!: HTMLImageElement;
      }

    This breaks because element is a getter on Component. This declaration then shadows the getter declaration on the base class and stomps it to undefined (effectively Object.defineProperty(this, 'element', void 0). (It would be nice to use declare here, but that doesn't work: you cannot use declare with a getter in a concrete subclass.)

    Two solutions:

    1. Annotate locally (slightly more annoying, but less likely to troll you):

       class Image extends Component {
         useElement() {
           let element = this.element as HTMLImageElement;
           console.log(element.src);
         }
       }
    2. Use a local getter:

       class Image extends Component {
         // We do this because...
         get _element(): HTMLImageElement {
           return this.element as HTMLImageElement;
         }
      
         useElement() {
           console.log(this._element.src);
         }
       }

      Notably, this is not a problem for Glimmer components, so migrating to Octane will also help!

  • const enum is not supported at all. You will need to replace all uses of const enum with simply enum or constants.

  • Using ES5 getters or setters with this type annotations is not supported through at least Babel 7.3. However, they should also be unnecessary with ES6 classes, so you can simply remove the this type annotation.

  • Trailing commas after rest function parameters (function foo(...bar[],) {}) are disallowed by the ECMAScript spec, so Babel also disallows them.

  • Re-exports of types have to be disambiguated to be types, rather than values. Neither of these will work:

    export { FooType } from 'foo';
    import { FooType } from 'foo';
    export { FooType };

    In both cases, Babel attempts to emit a value export, not just a type export, and fails because there is no actual value to emit. You can do this instead as a workaround:

    import * as Foo from 'foo';
    export type FooType = Foo.FooType;

Models

Ember Data 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.

For details about decorator usage, see our overview of how Ember's decorators work with TypeScript.

@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;
}

Very important: 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): Ember Data 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 Ember corresponds to an optional field on the model.

The safest type you can write for an Ember Data 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;
}

Relationships

Relationships between models in Ember Data 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, available since TypeScript 3.8:

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 is AsyncBelongsTo<Model>, where Model is the type of the model you are creating a relationship to.

  • If the value is false, the type is Model, where Model 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;
}

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 is AsyncHasMany<Model>, where Model is the type of the model you are creating a relationship to.

  • If the value is false, the type is SyncHasMany<Model>, where Model 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>;
}

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.

Components

New to Ember or the Octane edition specifically? You may want to read the Ember Guides’ material on Components first!

Glimmer Components are defined in one of three ways: with templates only, with a template and a backing class, or with only a backing class (i.e. a yield-only component). When using a backing class, you get a first-class experience using TypeScript! For type-checking Glimmer templates as well, see Glint.

A simple component

A very simple Glimmer component which lets you change the count of a value might look like this:

<button {{on "click" this.minus}}>&minus;</button>
{{this.count}}
<button {{on "click" this.plus}}>+</button>
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class Counter extends Component {
  @tracked count = 0;

  @action plus() {
    this.count += 1;
  }

  @action minus() {
    this.count -= 1;
  }
}

Notice that there are no type declarations here – but this is actually a well-typed component. The type of count is number, and if we accidentally wrote something like this.count = "hello" the compiler would give us an error.

Adding arguments and giving them a type

So far so good, but of course most components aren’t quite this simple! Instead, they’re invoked by other templates and they can invoke other components themselves in their own templates.

Glimmer components can receive both arguments and attributes when they are invoked. When you are working with a component’s backing class, you have access to the arguments but not to the attributes. The arguments are passed to the constructor, and then available as this.args on the component instance afterward.

Since the implementation of RFC 748, Glimmer and Ember components accept a Signature type parameter as part of their definition. This parameter is expected to be an object type with (up to) three members: Args, Element and Blocks.

Args represents the arguments your component accepts. Typically this will be an object type mapping the names of your args to their expected type. For example:

export interface MySignature {
  Args: {
    arg1: string;
    arg2: number;
    arg3: boolean;
  }
}

If no Args key is specified, it will be a type error to pass any arguments to your component. You can read more about Element and Block in the Glint Component Signatures documentation.

Let’s imagine a component which just logs the names of its arguments when it is first constructed. First, we must define the Signature and pass it into our component, then we can use the Args member in our Signature to set the type of args in the constructor:

import Component from '@glimmer/component';

const log = console.log.bind(console);

export interface ArgsDisplaySignature {
  Args: {
    arg1: string;
    arg2: number;
    arg3: boolean;
  }
}

export default class ArgsDisplay extends Component<ArgsDisplaySignature> {
  constructor(owner: unknown, args: ArgsDisplaySignature['Args']) {
    super(owner, args);

    Object.keys(args).forEach(log);
  }
}

If you’re used to the classic Ember Object model, there are two important differences in the constructor itself:

  • we use super instead of this._super

  • we must call super before we do anything else with this, because in a subclass this is set up by running the superclass's constructor first (as implied by the JavaScript spec)

Notice that we have to start by calling super with owner and args. This may be a bit different from what you’re used to in Ember or other frameworks, but is normal for sub-classes in TypeScript today. If the compiler just accepted any ...arguments, a lot of potentially very unsafe invocations would go through. So, instead of using ...arguments, we explicitly pass the specific arguments and make sure their types match up with what the super-class expects.

This might change in the future! If TypeScript eventually adds support for “variadic kinds”, using ...arguments could become safe.

The types for owner here and args line up with what the constructor for Glimmer components expect. The owner is specified as unknown because this is a detail we explicitly don’t need to know about. The args are the Args from the Signature we defined.

The args passed to a Glimmer Component are available on this, so we could change our definition to return the names of the arguments from a getter:

import Component from '@glimmer/component';

export interface ArgsDisplaySignature {
  Args: {
    arg1: string;
    arg2: number;
    arg3: boolean;
  }
}

export default class ArgsDisplay extends Component<ArgsDisplaySignature> {
  get argNames(): string[] {
    return Object.keys(this.args);
  }
}
<p>The names of the <code>@args</code> are:</p>
<ul>
  {{#each this.argNames as |argName|}}
    <li>{{argName}}</li>
  {{/each}}
</ul>

Understanding args

Now, looking at that bit of code, you might be wondering how it knows what the type of this.args is. In the constructor version, we explicitly named the type of the args argument. Here, it seems to just work automatically. This works because the type definition for a Glimmer component looks roughly like this:

export default class Component<Args extends {} = {}> {
  readonly args: Args;

  constructor(owner: unknown, args: Args);
}

Not sure what’s up with <Args> at all? We highly recommend the TypeScript Deep Dive book’s chapter on generics to be quite helpful in understanding this part.

The type signature for Component, with Args extends {} = {}, means that the component always has a property named args —

  • with the type Args

  • which can be anything that extends the type {} – an object

  • and defaults to being just an empty object – = {}

This is analogous to the type of Array : since you can have an array of string , or an array of number or an array of SomeFancyObject , the type of array is Array<T> , where T is the type of thing in the array, which TypeScript normally figures out for you automatically at compile time:

let a = [1, 2, 3];  // Array<number>
let b = ["hello", "goodbye"]; // Array<string>

In the case of the Component, we have the types the way we do so that you can’t accidentally define args as a string, or undefined , or whatever: it has to be an object. Thus, Component<Args extends {}> . But we also want to make it so that you can just write extends Component , so that needs to have a default value. Thus, Component<Args extends {} = {}>.

Giving args a type

Now let’s put this to use. Imagine we’re constructing a user profile component which displays the user’s name and optionally an avatar and bio. The template might look something like this:

<div class='user-profile' ...attributes>
  {{#if this.avatar}}
    <img src={{this.avatar}} class='user-profile__avatar'>
  {{/if}}
  <p class='user-profile__bio'>{{this.userInfo}}</p>
</div>

Then we could capture the types for the profile with an interface representing the arguments:

import Component from '@glimmer/component';
import { generateUrl } from '../lib/generate-avatar';

interface User {
  name: string;
  avatar?: string;
  bio?: string;
}

export default class UserProfile extends Component<User> {
  get userInfo(): string {
    return this.args.bio ? `${this.args.name} ${this.args.bio}` : this.args.name;
  }

  get avatar(): string {
    return this.args.avatar ?? generateUrl();
  }
}

Assuming the default tsconfig.json settings (with strictNullChecks: true), this wouldn't type-check if we didn't check whether the bio argument were set.

Generic subclasses

If you'd like to make your own component subclass-able, you need to make it generic as well.

Are you sure you want to provide an inheritance-based API? Oftentimes, it's easier to maintain (and involves less TypeScript hoop-jumping) to use a compositional API instead. If you're sure, here's how!

import Component from '@glimmer/component';

export interface FancyInputArgs {
  // ...
}

export default class FancyInput<Args extends FancyInputArgs = FancyInputArgs> extends Component<Args> {
  // ...
}

Requiring that Args extends FancyInputArgs means that subclasses can have more than these args, but not fewer. Specifying that the Args = FancyInputArgs means that they default to just being FancyInputArgs, so users don't need to supply an explicit generic type parameter here unless they're adding more arguments to the class.

Understanding the @types Package Names

You may be wondering why the packages added to your package.json and described in Installation: Other packages this addon installs are named things like @types/ember__object instead of something like @types/@ember/object. This is a conventional name used to allow both the compiler and the DefinitelyTyped publishing infrastructure (types-publisher) to handle scoped packages, documented under What about scoped packages? in the DefinitelyTyped README.

See also:

  • Microsoft/types-publisher#155

  • Microsoft/Typescript#14819

Testing

Testing with TypeScript mostly works just the same as you'd expect in a non-TypeScript Ember application—so if you're just starting out with Ember, we recommend you read the official Ember Testing Guides first. The rest of this guide assumes you're already comfortable with testing in Ember!

When working with TypeScript in Ember tests, there are a few differences in your experience, and there are also differences in how you should handle testing app code vs. addon code.

App tests

One major difference when working with TypeScript in app code is that once your app is fully converted, there are a bunch of kinds of tests you just don't need to write any more: things like testing bad inputs to functions. We'll use an admittedly silly and contrived example here, an add function to add two numbers together, so that we can focus on the differences between JavaScript and TypeScript, rather than getting hung up on the details of this particular function.

First, the function we're testing might look like this.

Here we’re using the assert from @ember/debug. If you’re not familiar with it, you might want to take a look at its API docs! It’s a development-and-test-only helper that gets stripped from production builds, and is very helpful for this kind of thing!

// app/utils/math.js

export function add(a, b) {
  assert(
    'arguments must be numbers',
    typeof a === number && typeof b === number
  );

  return a + b;
}

Then the test for it might look something like this:

// tests/unit/utils/math-test.js

import { module, test } from 'qunit';
import { add } from 'app/utils/math';

module('the `add` function', function(hooks) {
  test('adds numbers correctly', function(assert) {
    assert.equal('2 + 2 is 4', add(2, 2), 4);
    assert.notEqual('2 + 2 is a number', add(2, 2), NaN);
    assert.notEqual('2 + 2 is not infinity', add(2, 2), Infinity);
  });

  test('throws an error with strings', function(assert) {
    assert.throws(
      'when the first is a string and the second is a number',
      () => add('hello', 1)
    );
    assert.throws(
      'when the first is a number and the second is a string',
      () => add(0, 'hello')
    );
    assert.throws(
      'when both are strings',
      () => add('hello', 'goodbye')
    );
  })
});

In TypeScript, that wouldn't make any sense at all, because we'd simply add the types to the function declaration:

// app/utils/math.ts

export function add(a: number, b: number): number {
  assert(
    'arguments must be numbers',
    typeof a === number && typeof b === number
  );

  return a + b;
}

We might still write tests to make sure what we actually got back was what we expected—

// tests/unit/utils/math-test.ts

import { module, test } from 'qunit';
import { add } from 'app/utils/math';

module('the `add` function', function(hooks) {
  test('adds numbers correctly', function(assert) {
    assert.equal('2 + 2 is 4', add(2, 2), 4);
    assert.notEqual('2 + 2 is a number', add(2, 2), NaN);
    assert.notEqual('2 + 2 is not infinity', add(2, 2), Infinity);
  });
});

—but there are a bunch of things we don't need to test. All of those special bits of handling for the case where we pass in a string or undefined or whatever else? We can drop that. Notice, too, that we can drop the assertion from our function definition, because the compiler will check this for us:

// app/utils/math.ts

export function add(a: number, b: number): number {
 return a + b;
}

Addon tests

Note, however, that this only applies to app code. If you're writing an Ember addon (or any other library), you cannot assume that everyone consuming your code is using TypeScript. You still need to account for these kinds of cases. This will require you to do something that probably feels a bit gross: casting a bunch of values as any for your tests, so that you can test what happens when people feed bad data to your addon!

Let's return to our silly example with an add function. Our setup will look a lot like it did in the JavaScript-only example—but with some extra type coercions along the way so that we can invoke it the way JavaScript-only users might.

First, notice that in this case we’ve added back in our assert in the body of the function. The inputs to our function here will get checked for us by any TypeScript users, but this way we are still doing the work of helping out our JavaScript users.

function add(a: number, b: number): number {
  assert(
    'arguments must be numbers',
    typeof a === number && typeof b === number
  );

  return a + b;
}

Now, back in our test file, we’re similarly back to testing all those extra scenarios, but here TypeScript would actually stop us from even having these tests work at all if we didn’t use the as operator to throw away what TypeScript knows about our code!

// tests/unit/utils/math-test.js

import { module, test } from 'qunit';
import { add } from 'app/utils/math';

module('the `add` function', function(hooks) {
  test('adds numbers correctly', function(assert) {
    assert.equal('2 + 2 is 4', add(2, 2), 4);
    assert.notEqual('2 + 2 is a number', add(2, 2), NaN);
    assert.notEqual('2 + 2 is not infinity', add(2, 2), Infinity);
  });

  test('throws an error with strings', function(assert) {
    assert.throws(
      'when the first is a string and the second is a number',
      () => add('hello' as any, 1)
    );
    assert.throws(
      'when the first is a number and the second is a string',
      () => add(0, 'hello' as any)
    );
    assert.throws(
      'when both are strings',
      () => add('hello' as any, 'goodbye' as any)
    );
  })
});

Gotchas

The TestContext

A common scenario in Ember tests, especially integration tests, is setting some value on the this context of the tests, so that it can be used in the context of the test. For example, we might need to set up a User type to pass into a Profile component.

We’re going to start by defining a basic User and Profile so that we have a good idea of what we’re testing.

The User type is very simple, just an interface:

// app/types/user.ts

export default interface User {
  displayName: string;
  avatarUrl?: string;
}

Then our component might be defined like this:

{{! app/components/profile.hbs }}

<div class='user-profile' ...attributes>
  <img
    src={{this.avatar}}
    alt={{this.description}}
    class='avatar'
    data-test-avatar
  />
  <span class='name' data-test-name>{{@displayName}}</span>
</div>
import Component from '@glimmer/component';
import User from 'app/types/user';
import { randomAvatarURL  } from 'app/utils/avatar';

export default class Profile extends Component<User> {
  get avatar() {
    return this.args.avatar ?? randomAvatarURL();
  }

  get description() {
    return this.args.avatar
      ? `${this.args.displayName}'s custom profile picture`
      : 'a randomly generated placeholder avatar';
  }
}

Not familiar with how we define a Glimmer Component and its arguments? Check out our guide!

Now, with that setup out of the way, let’s get back to talking about the text context! We need to set up a User to pass into the test. With TypeScript on our side, we can even make sure that it actually matches up to the type we want to use!

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';

import User from 'app/types/user';

module('Integration | Component | Profile', function(hooks) {
  setupRenderingTest(hooks);

  test('given a user with an avatar', async function(assert) {
    this.user: User = {
      displayName: 'Rey',
      avatar: 'https://example.com/star-wars/rey',
    };

    await render(hbs`<Profile @user={{this.user}}`);

    assert.dom('[data-test-name]').hasText(this.user.displayName);

    assert.dom('[data-test-avatar]')
      .hasAttribute('src', this.user.avatar);
    assert.dom('[data-test-avatar]')
      .hasAttribute('alt', `${this.user.displayName}'s custom profile picture`);
  });

  test('given a user without an avatar', async function(assert) {
    this.user: User = {
      displayName: 'Rey',
    };

    await render(hbs`<Profile @user={{this.user}}`);

    assert.dom('[data-test-name]').hasText(this.user.displayName);

    assert.dom('[data-test-avatar]')
      .hasAttribute('src', /rando-avatars-yo/);
    assert.dom('[data-test-avatar]')
      .hasAttribute('alt', 'a randomly generated placeholder avatar');
  });
});

This is a decent test, and TypeScript actually makes the experience of writing certain parts of it pretty nice. Unfortunately, though, it won’t type-check. TypeScript reports that the user field doesn't exist on the TestContext. Now, TypeScript does know that QUnit sets up that helpfully-named TestContext—so a lot of the things we can do in tests work out of the box—but we haven’t told TypeScript that this now has a user property on it.

To inform TypeScript about this, we need to tell it that the type of this in each test assertion includes the user property, of type User. We’ll start by importing the TestContext defined by Ember’s test helpers, and extending it:

import { TestContext } from '@ember/test-helpers';

import User from 'app/types/user';

interface Context extends TestContext {
  user: User;
}

Then, in every test callback, we need to specify the this type:

test('...', function(this: Context, assert) {

});

Putting it all together, this is what our updated test definition would look like:

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, TestContext } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';

import User from 'app/types/user';

interface Context extends TestContext {
  user: User;
}

module('Integration | Component | Profile', function(hooks) {
  setupRenderingTest(hooks);

  test('given a user with an avatar', async function(this: Context, assert) {
    this.user: User = {
      displayName: 'Rey',
      avatar: 'https://example.com/star-wars/rey',
    };

    await render(hbs`<Profile @user={{this.user}}`);

    assert.dom('[data-test-name]').hasText(this.user.displayName);

    assert.dom('[data-test-avatar]')
      .hasAttribute('src', this.user.avatar);
    assert.dom('[data-test-avatar]')
      .hasAttribute('alt', `${this.user.displayName}'s custom profile picture`);
  });

  test('given a user without an avatar', async function(this: Context, assert) {
    this.user: User = {
      displayName: 'Rey',
    };

    await render(hbs`<Profile @user={{this.user}}`);

    assert.dom('[data-test-name]').hasText(this.user.displayName);

    assert.dom('[data-test-avatar]')
      .hasAttribute('src', /rando-avatars-yo/);
    assert.dom('[data-test-avatar]')
      .hasAttribute('alt', 'a randomly generated placeholder avatar');
  });
});

Now everything type-checks again, and we get the nice auto-completion we’re used to when dealing with this.user in the test body.

If you’ve been around TypeScript a little, and you look up the type of the TestContext and realize its an interface, you might be tempted to reach for declaration merging here. Don’t! If you do that, every single test in your entire application will now have a user: User property on it!

There are still a couple things to be careful about here, however. First, we didn’t specify that the this.user property was optional. That means that TypeScript won’t complain if you do this.user before assigning to it. Second, every test in our module gets the same Context. Depending on what you’re doing, that may be fine, but you may end up needing to define multiple distinct test context extensions. If you do end up needing to define a bunch of different test context extension, that may be a sign that this particular set of tests is doing too much. That in turn is probably a sign that this particular component is doing too much!

QUnit Dom for Component tests

When writing Component Tests, you will use lots of assert.dom() calls. Out of the box, Typescript will complain that Property 'dom' does not exist on type 'Assert'..

This can be fixed by importing qunit-dom in your test module:

import 'qunit-dom';

Conflicting Type Dependencies

You will sometimes see Duplicate identifier errors when type-checking your application.

An example duplicate identifier error:

This occurs whenever your yarn.lock or package-lock.json files include more than a single copy of a given set of type definitions—here, types for @ember/object, named @types/ember__object. See below for details on the package manager behavior, and Understanding the Package Names for details on the package names.

Workarounds

There are currently three recommended workarounds for this:

  • If using npm, you can use npm upgrade --depth=1 @types/ember__object to upgrade just that specific dependency and anywhere it is used as a transitive dependency of your top-level dependencies. You can also use its npm dedupe command, which may resolve the issue.

  • If using yarn, you can specify a specific version of the package to use in the "resolutions" key in package.json. For example, if you saw that you had @types/[email protected] from the default package installs but @types/[email protected] from some-cool-ts-addon, you could force yarn to use 3.0.8 like so:

  • You can identify the dependencies which installed the type dependencies transitively, and uninstall and reinstall them. For example, if running yarn why reported you had one version of @types/ember__object from , and one from some-cool-ts-addon, you could run this:

You may also be able to use , but this does not work 100% of the time, so if you try it and are still seeing the issues, try one of the solutions above.

Understanding the Problem

When you are using TypeScript in your Ember application, you consume Ember's types through , the tool the TypeScript team built to power the @types/* definitions. That tooling examines the dependencies implied by the package imports and generates a package.json with those types specified with a * dependency version. On initial installation of your dependencies, yarn installs the highest version of the package available, and correctly deduplicates that across both your own package and all the @types packages which reference each other.

However, later installs may introduce conflicting versions of the types, simply by way of yarn's normal update rules. TypeScript requires that there be one and only one type definition a given item can resolve to. Yarn actively avoids changing a previously-installed version of a transitive dependency when a newly installed package depends on the same dependency transitively. Thus, if one of your dependencies also depends on the same package from @types/* that you do, and you upgrade your dependence on that type by editing your package.json file and running yarn or npm install again, TypeScript will suddenly start offering the error described in detail above:

Duplicate identifier 'EmberObject'.ts(2300)

Let's imagine three packages, A, B, and C, where A is your app or library, and B and C have the following versions and dependencies:

  • C is currently at version 1.2.3.

  • B is at version 4.5.6. It depends on C with a * dependency. So the dependencies key in its package.json looks like this:

Now, you install only B (this is the equivalent of installing just the basic type definitions in your package):

The first time you install these, you will get a single version of C – 1.2.3.

Now, let's say that C publishes a new version, 1.2.4, and A (your app or library) adds a dependency on both C like so:

When your package manager runs (especially in the case of yarn), it goes out of its way to leave the existing installation of C in place, while adding a new version for you as a top-level consumer. So now you have two versions of C installed in your node_modules directory: 1.2.3 (for B) and 1.2.4 (for A, your app or library).

What's important to understand here is that this is exactly the behavior you want as the default in the Node ecosystem. Automatically updating a transitive dependency—even when the change is simply a bug fix release—can cause your entire app or library to stop working. If one of your dependencies accidentally depended on that buggy behavior, and adding a direct dependency on the fixed version caused the buggy version to be upgraded, you're just out of luck. Yarn accounts for this by resolving packages to the same version during initial installation, but leaving existing package resolutions as they are when adding new dependencies later.

Unfortunately, this is also the opposite of what you want for TypeScript, which needs a single source of truth for the types in your app or library. When you install the type definitions, and then later install a package which transitively depends on those type definitions, you end up with multiple sources of truth for the types.

Understanding the Workarounds

The solutions listed above both make sure npm apd Yarn only install a single version of the package.

  • Explicitly upgrading the dependencies or using dedupe resolves to a single version in npm.

  • Specifying a version in the "resolutions" field in your package.json simply forces Yarn to resolve every reference to that package to a single version. This actually works extremely well for types, but it means that every time you either update the types package(s) yourself or update a package which transitively depends on them, you have to edit this value manually as well.

  • Uninstalling and reinstalling both the impacted packages and all the packages which transitively depend on them gives you the same behavior as an initial install… because that's exactly what you're doing. The downside, of course, is that you have to identify and uninstall and reinstall all top-level packages which transitively depend on the files, and this introduces risk by way of other transitive dependencies being updated.

yarn tsc --noEmit yarn run v1.15.2
$ /Users/chris/dev/teaching/emberconf-2019/node_modules/.bin/tsc --noEmit
node_modules/@types/ember__object/index.d.ts:23:22 - error TS2300: Duplicate identifier 'EmberObject'.
23 export default class EmberObject extends CoreObject.extend(Observable) {}
~~~
node_modules/@types/ember__component/node_modules/@types/ember__object/index.d.ts:23:22
23 export default class EmberObject extends CoreObject.extend(Observable) {}
~~~ 'EmberObject' was also declared here.
node_modules/@types/ember__component/node_modules/@types/ember__object/index.d.ts:23:22 - error TS2300: Duplicate identifier 'EmberObject'.
8 export default class EmberObject extends CoreObject.extend(Observable) {}
~~~ 
node_modules/@types/ember__object/index.d.ts:23:22
23 export default class EmberObject extends CoreObject.extend(Observable) {}
~~~ 'EmberObject' was also declared here. Found 2 errors. error Command failed with exit code 1.
  {
    "resolutions": {
      "@types/ember__object": "3.0.8"
    }
  }
  yarn remove @types/ember some-cool-ts-addon
  yarn add -D @types/ember some-cool-ts-addon
  {
    "dependencies": {
      "C": "*"
    }
  }
{
  "dependencies": {
    "B": "~4.5.6"
  }
}
{
  "dependencies": {
    "B": "~4.5.6",
    "C": "~1.2.0"
  }
}
the normally-installed set of packages
yarn-deduplicate
DefinitelyTyped

Helpers

Helpers in Ember are just functions or classes with a well-defined interface, which means they largely Just Work™ with TypeScript. However, there are a couple things you’ll want to watch out for.

As always, you should start by reading and understanding the Ember Guide on Helpers!

Function-based helpers

The basic type of a helper function in Ember is:

type FunctionBasedHelper =
  (positional: unknown[], named: Record<string, unknown>) => string | void;

This represents a function which may have an arbitrarily-long list of positional arguments, which may be followed by a single dictionary-style object containing any named arguments.

There are three important points about this definition:

  1. positional is an array of unknown, of unspecified length.

  2. named is a Record.

  3. Both arguments are always set, but may be empty.

Let’s walk through each of these.

Handling positional arguments

The type is an array of unknown because we don’t (yet!) have any way to make templates aware of the information in this definition—so users could pass in anything. We can work around this using type narrowing—TypeScript’s way of using runtime checks to inform the types at runtime.

function totalLength(positional: unknown[]) {
  // Account for case where user passes no arguments
  assert(
    'all positional args to `total-length` must be strings',
    positional.every(arg => typeof arg === 'string')
  );

  // safety: we can cast `positional as string[]` because we asserted above
  return (positional as string[]).reduce((sum, s) => sum + s.length, 0);
}

Handling named arguments

We specified the type of named as a Record<string, unknown>. Record is a built-in TypeScript type representing a fairly standard type in JavaScript: an object being used as a simple map of keys to values. Here we set the values to unknown and the keys to string, since that accurately represents what callers may actually pass to a helper.

(As with positional, we specify the type here as unknown to account for the fact that the template layer isn’t aware of types yet.)

positional and named presence

Note that even if the user passes no arguments, both positional and named are always present. They will just be empty in that case. For example:

import { helper } from '@ember/component/helper';

const describe = (entries: string): string => (entries.length > 0 ? entries : '(none)');

export function showAll(positional: unknown[], named: Record<string, unknown>) {
  // pretty print each item with its index, like `0: { neat: true }` or
  // `1: undefined`.
  const positionalEntries = positional
    .reduce<string[]>((items, arg, index) => items.concat(`${index}: ${JSON.stringify(arg)}`), [])
    .join(', ');

  // pretty print each item with its name, like `cool: beans` or
  // `answer: 42`.
  const namedEntries = Object.keys(named)
    .reduce<string[]>(
      (items, key) => items.concat(`${key}: ${JSON.stringify(named[key], undefined, 2)}`),
      []
    )
    .join(', ');

  return `positional: ${describe(positionalEntries)}\nnamed: ${describe(namedEntries)}`;
}

export default helper(showAll);

Putting it all together

Given those constraints, let’s see what a (very contrived) actual helper might look like in practice. Let’s imagine we want to take a pair of strings and join them with a required separator and optional prefix and postfixes:

import { helper } from '@ember/component/helper';
import { assert } from '@ember/debug';
import { is } from '../../type-utils'

export function join(positional: [unknown, unknown], named: Dict<unknown>) {
  assert(
    `'join' requires two 'string' positional parameters`,
    is<[string, string]>(
      positional,
      positional.length === 2 &&
      positional.every(el => typeof el === 'string')
    )
  );
  assert(`'join' requires argument 'separator'`, typeof named.separator === 'string');

  const joined = positional.join(named.separator);
  const prefix = typeof named.prefix === 'string' ? named.prefix : '';

  return `${prefix}${joined}`;
}

export default helper(join);

Class-based helpers

The basic type of a class-based helper function in Ember is:

interface ClassBasedHelper {
  compute(positional?: unknown[], named?: Record<string, unknown>): string | void;
}

Notice that the signature of compute is the same as the signature for the function-based helper! This means that everything we said above applies in exactly the same way here. The only differences are that we can have local state and, by extending from Ember’s Helper class, we can hook into the dependency injection system and use services.

import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import Authentication from 'my-app/services/authentication';

export default class Greet extends Helper {
  @service authentication: Authentication;

  compute() {
    return this.authentication.isAuthenticated
      ? `Welcome back, ${authentication.userName}!`
      : 'Sign in?';
}

For more details on using decorators, see our guide to using decorators. For details on using services, see our guide to services.

Using TypeScript With Ember Effectively

Incremental adoption

If you are porting an existing app to TypeScript, you can install this addon and migrate your files incrementally by changing their extensions from .js to .ts. As TypeScript starts to find errors (and it usually does!), make sure to celebrate your wins—even if they're small!—with your team, especially if some people are not convinced yet. We would also love to hear your stories!

Some specific tips for success on the technical front:

First, use the strictest strictness settings that our typings allow (currently all strictness settings except strictFunctionTypes). While it may be tempting to start with the loosest strictness settings and then to tighten them down as you go, this will actually mean that "getting your app type-checking" will become a repeated process—getting it type-checking with every new strictness setting you enable—rather than something you do just once.

The full recommended strictness settings in your "compilerOptions" hash (which are also the settings generated by the ember-cli-typescript blueprint):

A good approach is to start at your "leaf" modules (the ones that don't import anything else from your app, only Ember or third-party types) and then work your way back inward toward the most core modules that are used everywhere. Often the highest-value modules are your Ember Data models and any core services that are used everywhere else in the app – and those are also the ones that tend to have the most cascading effects (having to update tons of other places in your app) when you type them later in the process.

Finally, leave "noEmitOnError": true (the default) in the "compilerOptions" hash in your tsconfig.json. This will fail your build if you have type errors, which gives you the fastest feedback as you add types.

What about missing types?

There are two schools of thought on how to handle things you don't have types for as you go:

  • Liberally use any for them and come back and fill them in later. This will let you do the strictest strictness settings but with an escape hatch that lets you say "We will come back to this when we have more idea how to handle it." This approach lets you move faster, but means you will still have lots of runtime type errors: any just turns the type-checker off for anything touching those modules. You’ll have to come back later and clean those up, and you’ll likely have more difficult refactorings to do at that time.

  • Go more slowly, but write down at least minimally accurate types as you go. (This is easier if you follow the leaves-first strategy recommended above.) This is much slower going, and can feel harder because you can’t just skip over things. Once you complete the work for any given module, though, you can be confident that everything is solid and you won’t have to revisit it in the future.

There is an inherent tradeoff between these two approaches; which works best will depend on your team and your app.

Install other types!

You'll want to use other type definitions as much as possible. The first thing you should do, for example, is install the types for your testing framework of choice: @types/ember-mocha or @types/ember-qunit. Beyond that, look for types from other addons: it will mean writing any a lot less and getting a lot more help both from your editor and from the compiler.

Where can I find types? Some addons will ship them with their packages, and work out of the box. For others, you can search for them on , or on npm under the @types namespace. (In the future we hope to maintain a list of known types; keep your eyes open!)

The types directory

During installation, we create a types directory in the root of your application and add a "paths" mapping that includes that directory in any type lookups TypeScript tries to do. This is convenient for a few things:

  • global types for your package (see the next section)

  • writing types for third-party/vendor packages which do not have any types

  • developing types for an addon which you intend to upstream later

These are all fallbacks, of course, you should use the types supplied directly with a package

Global types for your package

At the root of your application or addon, we include a types/<your app> directory with an index.d.ts file in it. Anything which is part of your application but which must be declared globally can go in this file. For example, if you have data attached to the Window object when the page is loaded (for bootstrapping or whatever other reason), this is a good place to declare it.

In the case of applications (but not for addons), we also automatically include declarations for Ember's prototype extensions in this index.d.ts file, with the Array prototype extensions enabled and the Function prototype extensions commented out. You should configure them to match your own config (which we cannot check during installation). If you are , you can remove these declarations entirely; we include them because they're enabled in most Ember applications today.

Environment configuration typings

Along with the @types/ files mentioned above, ember-cli-typescript adds a starter interface for config/environment.js in app/config/environment.d.ts. This interface will likely require some changes to match your app.

We install this file because the actual config/environment.js is (a) not actually identical with the types as you inherit them in the content of an application, but rather a superset of what an application has access to, and (b) not in a the same location as the path at which you look it up. The actual config/environment.js file executes in Node during the build, and Ember CLI writes its result as <my-app>/config/environment into your build for consumption at runtime.

String-keyed lookups

Ember makes heavy use of string-based APIs to allow for a high degree of dynamicism. With some limitations, you can nonetheless use TypeScript very effectively to get auto-complete/IntelliSense as well as to accurately type-check your applications.

A few of the most common speed-bumps are listed here to help make this easier:

Nested keys in get or set

In general, this.get and this.set will work as you'd expect if you're doing lookups only a single layer deep. Things like this.get('a.b.c') don't (and can't ever!) type-check; see the blog posts for a more detailed discussion of why.

The workaround is simply to do one of two things:

  1. The type-safe approach. This will typecheck, but is both ugly and only works *if there are no nulls or undefineds along the way. If nested is null at runtime, this will crash!

  2. Using // @ts-ignore. This will not do any type-checking, but is useful for the cases where you are intentionally checking a path which may be null or undefined anywhere long it.

    It's usually best to include an explanation of why you're ignoring a lookup!

Service and controller injections

Ember does service and controller lookups with the inject functions at runtime, using the name of the service or controller being injected up as the default value—a clever bit of metaprogramming that makes for a nice developer experience. TypeScript cannot do this, because the name of the service or controller to inject isn't available at compile time in the same way.

The officially supported method for injections with TypeScript uses decorators.

Then we can use the service as we usually would with a decorator, but adding a type annotation to it so TypeScript knows what it's looking at:

Note that we need the MySession type annotation this way, but we don't need the string lookup (unless we're giving the service a different name than the usual on the class, as in Ember injections in general). Without the type annotation, the type of session would just be any. This is because decorators are not allowed to modify the types of whatever they decorate. As a result, we wouldn't get any type-checking on that session.login call, and we wouldn't get any auto-completion either. Which would be really sad and take away a lot of the reason we're using TypeScript in the first place!

Also notice . This tells TypeScript that the property will be configured by something outside the class (in this case, the decorator), and guarantees it emits spec-compliant JavaScript.

(This also holds true for all other service injections, computed property macros, and Ember Data model attributes and relationships.)

Earlier Ember versions

A couple notes for consumers on earlier Ember versions:

On Ember versions earlier than 3.1, you'll want to wrap your service type in , because are not available there, which means that instead of accessing the service via this.mySession, you would have to access it as this.get('mySession') or get(this, 'mySession').

On Ember versions earlier than 3.6, you may encounter problems when providing type definitions like this:

When invoked via a template {{user-profile username='example123'}}, you would expect that username would have the value of example123, however prior to the native class feature released in Ember 3.6, this will result in username being undefined.

For users who remain on Ember versions below 3.6, please use

Ember Data lookups

We use the same basic approach for Ember Data type lookups with string keys as we do for service or controller injections. As a result, once you add the module and interface definitions for each model, 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 (admittedly kind of hairy!) types; just write your Ember Data calls like normal and everything should just work.

The declarations and changes you need to add to your existing files are:

  • Models

  • Adapters

  • Serializers

  • Transforms

Opt-in unsafety

Also notice that unlike with service and controller injections, there is no unsafe fallback method by default, because there isn't an argument-less variant of the functions to use as there is for Service and Controller injection. If for some reason you want to opt out of the full type-safe lookup for the strings you pass into methods like findRecord, adapterFor, and serializerFor, you can add these declarations somewhere in your project:

However, we strongly recommend that you simply take the time to add the few lines of declarations to each of your Model, Adapter, and Serializer instances instead. It will save you time in even the short run!

Fixing the Ember Data error TS2344 problem

If you're developing an Ember app or addon and not using Ember Data (and accordingly not even have the Ember Data types installed), you may see an error like this and be confused:

This happens because the types for Ember's test tooling includes the types for Ember Data because the this value in several of Ember's test types can include a reference to the Ember Data Store class.

The fix: add a declaration like this in a new file named ember-data.d.ts in your types directory:

This works because (a) we include things in your types directory automatically and (b) TypeScript will merge this module and interface declaration with the main definitions for Ember Data from DefinitelyTyped behind the scenes.

If you're developing an addon and concerned that this might affect consumers, it won't. Your types directory will never be referenced by consumers at all!

Class property setup errors

Some common stumbling blocks for people switching to ES6 classes from the traditional EmberObject setup:

  • Assertion Failed: InjectedProperties should be defined with the inject computed property macros. – You've written someService = inject() in an ES6 class body in Ember 3.1+. Replace it with the .extend approach or by using decorators(@service or @controller) as discussed . Because computed properties of all sorts, including injections, must be set up on a prototype, not on an instance, if you try to use class properties to set up injections, computed properties, the action hash, and so on, you will see this error.

  • Assertion Failed: Attempting to lookup an injected property on an object without a container, ensure that the object was instantiated via a container. – You failed to pass ...arguments when you called super in e.g. a component class constructor. Always do super(...arguments), not just super(), in your constructor.

Type definitions outside node_modules/@types

By default, the TypeScript compiler loads all type definitions found in node_modules/@types. If the type defs you need are not found there and are not supplied in the root of the package you're referencing, you can register a custom value in paths in the tsconfig.json file. See the for details.

{
  "compilerOptions": {
    // Strictness settings -- you should *not* change these: Ember code is not
    // guaranteed to type check with these set to looser values.
    "strict": true,
    "noUncheckedIndexedAccess": true,

    // You should feel free to change these, especially if you are already
    // covering them via linting (e.g. with @typescript-eslint).
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
  }
}
import { get } from '@ember/object';

// -- Type-safe but ugly --//
get(get(get(someObject, 'deeply'), 'nested'), 'key');
// @ts-ignore
get(someObject, 'deeply.nested.key');
// my-app/services/my-session.ts
import Service from '@ember/service';
import RSVP from 'rsvp';

export default class MySession extends Service {
  login(email: string, password: string): RSVP.Promise<string> {
    // login and return the confirmation message
  }
}

declare module '@ember/service' {
  interface Registry {
    'my-session': MySession;
  }
}
// my-app/components/user-profile.ts
import Component from '@ember/component';
import { inject as service } from '@ember/service';

import MySession from 'my-app/services/my-session';

export default class UserProfile extends Component {
  @service declare mySession: MySession;

  login(email: string, password: string) {
    this.mySession.login(email, password);
  }
}
import Component from '@ember/component';

export default class UserProfile extends Component {
  username?: string;
}
import Model from '@ember-data/model';

export default class UserMeta extends Model {}

declare module 'ember-data/types/registries/model' {
  export default interface ModelRegistry {
    'user-meta': UserMeta;
  }
}
import Adapter from '@ember-data/adapter';

export default class UserMeta extends Adapter {}

declare module 'ember-data/types/registries/adapter' {
  export default interface AdapterRegistry {
    'user-meta': UserMeta;
  }
}
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 Transform from '@ember-data/serializer/transform';

export default class ColorTransform extends Transform {}

declare module 'ember-data/types/registries/transform' {
  export default interface TransformRegistry {
    color: ColorTransform;
  }
}
import Model from '@ember-data/model';
import Adapter from '@ember-data/adapter';
import Serializer from '@ember-data/serializer';

declare module 'ember-data/types/registries/model' {
  export default interface ModelRegistry {
    [key: string]: Model;
  }
}
declare module 'ember-data/types/registries/adapter' {
  export default interface AdapterRegistry {
    [key: string]: Adapter;
  }
}
declare module 'ember-data/types/registries/serializer' {
  export default interface SerializerRegistry {
    [key: string]: Serializer;
  }
}
node_modules/@types/ember-data/index.d.ts(920,56): error TS2344: Type 'any' does not satisfy the constraint 'never'.
declare module 'ember-data/types/registries/model' {
  export default interface ModelRegistry {
    [key: string]: unknown;
  }
}
Definitely Typed
disabling Ember's prototype extensions
the declare property modifier
ComputedProperty
native ES5 getters
https://github.com/pzuraq/ember-native-class-polyfill
above
tsconfig.json docs
example of a build error during live reload