Declare your injections, or risk losing them!

When using Ember + TypeScript, the recommendation thus far for service injections has been to mark them as definite with ! (since they are not initialized in the class, but by the service injection decorator), e.g.

import { inject as service } from '@ember/service';

export default class MyComponent extends Component {
  @service myService!: MyService;
}

This has worked fine so far, but a compiler flag introduced in TypeScript 3.7 breaks this. The flag is useDefineForClassFields, which was added to better align with how public class fields will most likely be standardized. This flag is not yet enabled by default, but could be at some point in the future. You can read all about it in the TypeScript 3.7 release notes.

Note: The corresponding flag in @babel/plugin-proposal-class-properties is enabled by default (well, disabled since in Babel the loose option turns off using Object.defineProperty for class fields) but the Babel implementation of decorators checks if a decorator has not added an initializer and if not, clears the descriptor then later skips Object.defineProperty if the descriptor has been cleared. This means that (at least for now) service injection using ! will continue to work for users of ember-cli-typescript, but there's no guarantee that this will always be the case. The safer bet is to use declare (and it's describing the desired behavior better as I explain below).

The change in how class fields are initialized (in particular the fact that they'll be initialized to undefined if they have no instance initializer) breaks using a decorator for service injection. To demonstrate, consider the following decorator and class:

function service(target: any, propertyName: string) {
  target[propertyName] = "I'm a service!";
}

class Foo {
  @service myService!: unknown;
}

const foo = new Foo();

console.log(`What are you? ${foo.myService}`);

What we hope will happen is that foo.myService will be I'm a service!. And, in fact, it is by default:

What are you? I'm a service!

Try it yourself with this TypeScript Playground link
(click Run and see the Logs tab on the right)

But watch what happens when we enable useDefineForClassFields:

What are you? undefined

TypeScript Playground link

Not exactly what we were hoping for! 😢

If you look closely at the transpiled output (in the .JS tab) at the TypeScript Playground link above, you can see why this happened.

tl;dr? Object.defineProperty in the constructor overwrote the service injection on the class prototype, setting the value to undefined (void 0 evaluates to undefined).

The good news is TypeScript has a way to fix this.

So how do we fix it! We declare it!

The declare property modifier

If you've been using TypeScript for a bit, you've likely seen the declare keyword used to define ambient types, e.g.

declare function notWrittenInTypeScript(param1: string): boolean;

Which means: "ok TypeScript, I know you can't see that there's a function called notWrittenInTypescript that takes a string an returns a boolean, by trust me, it'll be there at run-time". These are super useful for defining types for things the TypeScript compiler doesn't know about like libraries written in JavaScript.

Along with useDefineForClassFields, TypeScript 3.7 introduced the ability to use declare on a class property, e.g.

class Foo {
  @service declare myService: unknown;
}

Using declare this way means basically the same thing as other uses. You're saying to the TypeScript compiler, "something outside of TypeScript will create and initialize this property". Which is exactly what service injection is doing! You don't do any initialization yourself in the class. Ember magic does it for you! As an added benefit, you no longer need to mark the property as definite with ! because no initializer is expected (in fact, it's an error if you try to add ! or an initializer).

Because declare tells TypeScript to just use the property for type information and don't try to initialize it, it fixes the issue with useDefineForClassFields:

What are you? I'm a service!

TypeScript Playground link

This syntax is also more exact than using ! because it's saying "something external to this class will initialize this property" rather than just "this property will be initialized". And declare will prevent someone from coming along and adding an initializer.

class Foo {
  @service declare myService: unknown = 'something'; // Error
}

For extra safety, you could even denote it as readonly:

class Foo {
  @service declare readonly myService: unknown;
  
  bar() {
    this.myService = 'something'; // Error
  }
}

Summary

So there you have it! In order to future-proof your service injections and not risk losing them, you should migrate to using the declare property modifier. To use declare in Ember, you'll need at least TypeScript 3.7, Babel 7.11, and ember-cli-typescript 4.0 (4.0.0-rc.1 is available as of this writing).