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):

{
  "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,
  }
}

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 Definitely Typed, 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 disabling Ember's prototype extensions, 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!

    import { get } from '@ember/object';
    
    // -- Type-safe but ugly --//
    get(get(get(someObject, 'deeply'), 'nested'), 'key');
  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.

    // @ts-ignore
    get(someObject, 'deeply.nested.key');

    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.

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

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:

// 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);
  }
}

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 the declare property modifier. 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 ComputedProperty, because native ES5 getters 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:

import Component from '@ember/component';

export default class UserProfile extends Component {
  username?: string;
}

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 https://github.com/pzuraq/ember-native-class-polyfill

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

    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;
      }
    }
  • Adapters

    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;
      }
    }
  • Serializers

    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;
      }
    }
  • Transforms

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

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:

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

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:

node_modules/@types/ember-data/index.d.ts(920,56): error TS2344: Type 'any' does not satisfy the constraint 'never'.

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:

declare module 'ember-data/types/registries/model' {
  export default interface ModelRegistry {
    [key: string]: unknown;
  }
}

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 above. 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 tsconfig.json docs for details.

Last updated