Typed Ember extends Confidence Part 2: Converting Your Ember App to TypeScript [2022 Update]

This article is part 2 of a series on converting your Ember app to TypeScript to foster confidence in your engineering team, based on my talk for EmberConf 2021 (and updated in 2022 based on the latest and greatest Ember + TypeScript practices). You can watch the full talk below, but note that this blog post will differ substantially since it has been updated. (You can see the old version here if you want to see how much has changed in the last year!)

We started with some basics: "What even is a type? What is TypeScript?" Now, we'll look at what TypeScript looks like in an Ember app before circling back to the benefits of TypeScript in the context of developer confidence.

Table of Contents

A Metatutorial

Let's convert an app to TypeScript! We'll use the Super Rentals app from the Ember Guides tutorial as our example. Super Rentals is a website for browsing interesting places to stay during your post-quarantine vacation.

Super Rentals is a very modern Ember app, using the latest and greatest Ember Octane features. Admittedly, using TypeScript with pre-Octane Ember was clunky. With Octane and native classes, however, using TypeScript with Ember is pretty straightforward.

If you are not familiar with Ember Octane idioms, I recommend following the Super Rentals tutorial before following this one. Otherwise, you can start with:

$ git clone https://github.com/ember-learn/super-rentals.git && cd super-rentals

Setup

Installing TypeScript

The first step is to run ember install ember-cli-typescript. Installing the ember-cli-typescript package adds everything you need to compile TypeScript with Ember.

$ ember install ember-cli-typescript

🚧 Installing packages…
  ember-cli-typescript,
  typescript,
  @types/ember,
  @types/ember-data,
  Etc…

create tsconfig.json
create app/config/environment.d.ts
create types/super-rentals/index.d.ts
create types/ember-data/types/registries/model.d.ts
create types/global.d.ts

This includes:

  • The typescript package itself.
  • A default tsconfig.json file.
  • Some basic utility types and directories.
  • And types packages for each of Ember's modules.

While Ember itself doesn't have types baked in (but they are coming soon!), there is a project called Definitely Typed that acts as a repository for types for hundreds of projects—including Ember. You install these types as packages, then import them the same way you would a JavaScript module.

LET'S COMMIT!

Installing Glint

In addition to TypeScript, we also need to install Glint. Glint is a template-aware tool for performing end-to-end TypeScript type-checking on your Ember project. With Glint, your templates will be checked against your TypeScript files and vice versa! 😍

To set up Glint, you first need to install it. (You'll also need to add the ember-modifier package if your project doesn't already have it, as Glint assumes you have it installed.)

$ yarn add --dev @glint/core @glint/environment-ember-loose ember-modifier

Then, add a Glint configuration file. The environment key is set to ember-loose, the environment recommended by the Glint team for Ember projects. We are also adding the optional include key so that we can gradually enable type-checking for our app. For now, include only '' so that Glint isn't checking anything yet.

# .glintrc.yml

environment: ember-loose

# FIXME: Remove include key before merge
include:
  - ''

Next, we need to import the types for the ember-loose environment. We'll also add a catch-all "template registry" for now so that we won't be bombarded with errors once we start type-checking our files. We'll talk about the template registry later, so try not to dwell on it now.

// types/super-rentals/index.d.ts

import '@glint/environment-ember-loose';

// NOTE: This import won't be necessary after Glint 0.8
import '@glint/environment-ember-loose/native-integration';

// FIXME: Remove this catch-all before merge
declare module '@glint/environment-ember-loose/registry' {
  export default interface Registry {
    [key: string]: any;
  }
}

// ...

Finally, to see helpful red squiggly lines in VSCode, install the typed-ember.glint-vscode extension. You may need to reload your VSCode window after installing to see errors. (NOTE: The Glint team recommends also disabling the vscode.typescript-language-features extension, but there are multiple bugs if you follow that advice.)

LET'S COMMIT!

Strict Mode

As I mentioned in Part 1, you can configure TypeScript's strictness. There are two ends of the spectrum here:

Start with all the checks disabled, then enable them gradually as you start to feel more comfortable with your TypeScript conversion. I do recommend switching to strict mode as soon as possible because strictness is sorta the point of TypeScript to avoid shipping detectable bugs in your code.

// tsconfig.json

{
  "compilerOptions": {
    "alwaysStrict": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strictBindCallApply": true,
    "strictFunctionTypes": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    // ...
  }
}

Alternatively, you can start in strict mode. This is the strategy we will use for converting Super Rentals, since I want you to see the full power of TypeScript.

// tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    // ...
  }
}

In fact, I want my TypeScript even stricter. In addition to strict mode, let's enable as many strict checks as we can. Why not!?

// tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    // ...
  }
}

I'm going to also add the typescript-eslint plugin, which adds even more checks:

yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

(👋👋 NOTE: If you're following along, you'll also need to add some boilerplate, and you remove babel-eslint/@babel/eslint-parser.)

And finally, let's change the noEmitOnError config from the ember-cli-typescript default of true to false. We'll talk about why in the next section.

// tsconfig.json

{
  "compilerOptions": {
    "noEmitOnError": false,
    // ...
  }
}

LET'S COMMIT!

Iterative Migration

Gradual Typing Hacks

Alright! Now that we have installed TypeScript, we can start converting files. Fortunately, TypeScript allows for gradual typing. This means that you can use TypeScript and JavaScript files interchangeably, so you can convert your app piecemeal.

Of course, many of your files might reference types in other files that haven't been converted yet. There are several strategies you can employ to avoid a chain-reaction resulting in having to convert your entire app at once:

  • TypeScript declaration files (.d.ts)—These files are a way to document TypeScript types for JavaScript files without converting them.
  • The unknown type—You can sometimes get pretty far just by annotating types as unknown.
  • The any type—Opt out of type checking for a value by annotating it as any.
  • The @ts-expect-error / @glint-expect-error directive—A better strategy than any, however, is to mark offending parts of your code with an "expect error" directive. This comment will ignore a type-checking error and allow the TypeScript compiler to assume that the value is of the type any. If the code stops triggering the error, TypeScript will let you know.

(Experienced TypeScript users may already be familiar with @ts-ignore. The difference is that @ts-ignore won't let you know when the code stops triggering the error. At Tilde, we've disallowed @ts-ignore in favor of @ts-expect-error. If you really want to dig into it, the TypeScript team provided guidelines about when to choose one over the other here.)

Our gradual typing strategy is why we are going to disable noEmitOnError for now. With noEmitOnError set to true, our app would not build if there were any type errors, which would mean we wouldn't be able to poke around in our code via debugger as we are investigating our types during conversion.

Where do we start?

OK, so we know we can convert our app in a piecemeal fashion. So, where do we start? There are several strategies to choose from:

  • Outer leaves first (aka Models first)—Models likely have the fewest non-Ember imports, so you won't have to use as many of our gradual typing hacks. This strategy is best if your app already uses Octane, since Octane getters might not always be compatible with pre-Octane computed properties. (👋👋 NOTE: see dependentKeyCompat, a whole 'nother can of worms).
  • Inner leaves first (aka Components first)—This strategy is best if you are converting to Octane simultaneously with TypeScript. You will need to make heavy use of our gradual typing hacks.
  • You touch it, you convert it—Whenever you are about to touch a file, convert it to TypeScript first. This strategy is best if you don't have time to convert everything at once.
  • Most fun first—Pick the files you are most curious about. Refactoring to TypeScript is an awesome way to build confidence in your understanding of a chunk of code. This strategy is also great for onboarding new team members.

The Tilde team tried all of these strategies for our half-Classic/half-Octane app and settled on a mix of "you touch it, you convert it" and "most fun first." For our Super Rentals conversion, however, we are going to approach the conversion "outer leaves first."

Models

Our outer-most leaf is the Rental model. In JavaScript, it looks like this:

// app/models/rental.js

import Model, { attr } from '@ember-data/model';

const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment'];

export default class RentalModel extends Model {
  @attr title;
  @attr owner;
  @attr city;
  @attr location;
  @attr category;
  @attr image;
  @attr bedrooms;
  @attr description;

  get type() {
    if (COMMUNITY_CATEGORIES.includes(this.category)) {
      return 'Community';
    } else {
      return 'Standalone';
    }
  }
}

The Rental model keeps track of various attributes about our vacation rentals. It also has a getter to categorize the type of rental into either "Community" or "Standalone."

Step One: Rename the file to TypeScript.

And...we're done! Congratulations! You've just written your first TypeScript class! Because all valid JavaScript is valid TypeScript, any JavaScript code will still compile as TypeScript code.

But...it looks like we have some type checking errors. We can see these errors automatically if we are using an editor with TypeScript integration, like VSCode, usually in the form of red squiggly underlines. Alternatively, you can run the TypeScript compiler manually in your terminal by running yarn tsc.

// app/models/rental.ts

import Model, { attr } from '@ember-data/model';

const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment'];

export default class RentalModel extends Model {
  // Member 'title' implicitly has an 'any' type.
  @attr title;
  // Member 'owner' implicitly has an 'any' type.
  @attr owner;
  // Member 'city' implicitly has an 'any' type.
  @attr city;
  // Member 'location' implicitly has an 'any' type.
  @attr location;
  // Member 'category' implicitly has an 'any' type.
  @attr category;
  // Member 'image' implicitly has an 'any' type.
  @attr image;
  // Member 'bedrooms' implicitly has an 'any' type.
  @attr bedrooms;
  // Member 'description' implicitly has an 'any' type.
  @attr description;

  // Missing return type on function.
  // eslint(@typescript-eslint/explicit-module-boundary-types)
  get type() {
    if (COMMUNITY_CATEGORIES.includes(this.category)) {
      return 'Community';
    } else {
      return 'Standalone';
    }
  }
}

Ok, it looks like we have a little more work to do. The type checking errors indicate that TypeScript has found the potential for a bug here. Let's start from the top.

Member 'title' implicitly has an 'any' type.

This error is telling us that we need to annotate the title attribute with a type. We can look at the seed data from the Super Rentals app to figure out what the type should be. It looks like the title is a string.

// public/api/rentals.json

{
  "data": [
    {
      "type": "rentals",
      "id": "grand-old-mansion",
      "attributes": {
        "title": "Grand Old Mansion", // It's a string!
        "owner": "Veruca Salt",
        "city": "San Francisco",
        "location": {
          "lat": 37.7749,
          "lng": -122.4194
        },
        "category": "Estate",
        "image": "<https://upload.wikimedia.org/mansion.jpg>",
        "bedrooms": 15,
        "description": "This grand old mansion sits..."
      }
    },
    // ...
  ]
}

@attr title: string;

Hmm...we have a new error now:

Property 'title' has no initializer and is not definitely assigned in the constructor.

This message is a little confusing, but here is what it means:

TypeScript expects properties to either:

  • Be declared with an initial value (e.g. title: string = 'Grand Old Mansion')
  • Be set in the constructor (e.g. constructor(title) { this.title = title; })
  • Or be allowed to be undefined (e.g. title: string | undefined)

TypeScript doesn't really know that the @attr decorator is making the property exist. In this case, we can tell TypeScript "someone else is setting this property" by marking the value with the declare property modifier:

@attr declare title: string;

Let's go ahead and resolve the rest of the squiggly lines on the attributes. For the most part, our attributes use JavaScript primitive types. For the location attribute, however, we declared a MapLocation interface to describe the properties on the location object.

And the last error is coming from ESLint, asking us to provide a return type for the type getter. Because we know that the type getter will always return either the string 'Community' or the string 'Standalone', we can put string in as the return type, or we can be extra specific and use a union of literal types for the return value.

// app/models/rental.ts

import Model, { attr } from '@ember-data/model';

const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment'];

interface MapLocation {
  lat: number;
  lng: number;
}

export default class RentalModel extends Model {
  @attr declare title: string;
  @attr declare owner: string;
  @attr declare city: string;
  @attr declare location: MapLocation;
  @attr declare category: string;
  @attr declare image: string;
  @attr declare bedrooms: number;
  @attr declare description: string;

  get type(): 'Community' | 'Standalone' {
    if (COMMUNITY_CATEGORIES.includes(this.category)) {
      return 'Community';
    } else {
      return 'Standalone';
    }
  }
}

Alright! We're free of type checking errors!

LET'S COMMIT!

One more thing about models before we move on. This model doesn't have any relationships on it, but if it did, we would use a similar strategy to what we did with attributes: the declare property modifier. The Ember Data types give us handy types that keep track of the many intricacies of Ember Data relationships. Cool!

import Model, {
  AsyncBelongsTo,
  AsyncHasMany,
  belongsTo,
  hasMany,
} from '@ember-data/model';
import Comment from 'my-app/models/comment';
import User from 'my-app/models/user';

export default class PostModel extends Model {
  @belongsTo('user') declare author: AsyncBelongsTo<User>;
  @hasMany('comments') declare comments: AsyncHasMany<Comment>;
}

Routes

The next leaf in includes routes. Let's convert the index route. It's pretty simple, with a model hook that accesses the Ember Data store and finds all of the rentals:

// app/routes/index.js

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class IndexRoute extends Route {
  @service store;

  model() {
    return this.store.findAll('rental');
  }
}

First, we'll rename the file to TypeScript...and once again we have some type-checking errors:

// app/routes/index.ts

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class IndexRoute extends Route {
  // Member 'store' implicitly has an 'any' type.
  @service store;

  // Missing return type on function.
  // eslint(@typescript-eslint/explicit-module-boundary-types)
  model() {
    return this.store.findAll('rental');
  }
}

The first error is Member 'store' implicitly has an 'any' type. We know that the type of the store service is a Store. We can import the Store type from '@ember-data/store' and add the type annotation.

@service store: Store;

And because the store is set by the @service decorator, we need to use the declare property modifier again.

@service declare store: Store;

And the last type-checking error is again the linter telling us we need a return type on the function. Here's a little hack you can use to check the return type. Pop void in as the return type. In this case, we get a type-checking error, as expected because we know the model hook does not actually return void :

// app/routes/index.ts

import Route from '@ember/routing/route';
import { service } from '@ember/service';
import Store from '@ember-data/store';

export default class IndexRoute extends Route {
  @service store: Store;

  // Type 'PromiseArray<any>' is not assignable to type 'void'.
  model(): void {
    return this.store.findAll('rental');
  }
}

Hmm... PromiseArray makes sense, but I wouldn't expect an array of any values. It should be a more specific type. Something seems wrong here.

We've run into one of the first gotchas of using TypeScript with Ember. Ember makes heavy use of string key lookups. For example, here we look up all of the rentals by passing the 'rental' string to the Store's findAll method. In order for TypeScript to know that the 'rental' string correlates with the RentalModel, we need to add some boilerplate to the end of the rental model file. The ember-cli-typescript installation added a ModelRegistry for this purpose, and we just need to register our RentalModel with the registry:

// app/models/rental.ts

export default class RentalModel extends Model {/* ... */}

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

And now, we get a much more useful error!

// app/routes/index.ts

import Route from '@ember/routing/route';
import { service } from '@ember/service';
import Store from '@ember-data/store';

export default class IndexRoute extends Route {
  @service store: Store;

  // Type 'PromiseArray<RentalModel>' is not assignable to type 'void'.
  model(): void {
    return this.store.findAll('rental');
  }
}

It looks like our return type is a Promise Array of Rental Models. We can add the appropriate imports and the type annotation, and now we have no more type-checking errors!

import Route from '@ember/routing/route';
import { service } from '@ember/service';
import Store from '@ember-data/store';

// FIXME: Do not merge with the "private" DS global exposed!
import DS from 'ember-data';
import RentalModel from 'super-rentals/models/rental';

export default class IndexRoute extends Route {
  @service declare store: Store;

  model(): DS.PromiseArray<RentalModel> {
    return this.store.findAll('rental');
  }
}

(NOTE: We have to use DS.PromiseArray for now because PromiseArray is private so the type isn't exported. We will fix this issue in a future commit. Let’s just keep this secret between us. 😛)

LET'S COMMIT!

Additionally, convert the Rental route.

Components

Next, let's try converting a component, the inner-most leaf of our app.

When choosing which components to convert first, I recommend choosing components that do not invoke any other components. The inner-most-inner-most leaf, if you will.

Additionally, we will convert each component in two passes:

  1. For the first pass, we will address basic issues that one would find with tsc alone.
  2. For the second pass, we will enable Glint for the component and address issues that arise from end-to-end type-checking.

We'll start with the Rentals::Filter component.

Rentals::Filter Component: TSC Pass

The Rentals::Filter component filters the list of vacation rentals based on a passed-in search query.

When we rename the file, we see some type-checking errors:

// app/components/rentals/filter.ts

import Component from '@glimmer/component';

export default class RentalsFilterComponent extends Component {
  // Missing return type on function.
  // eslint(@typescript-eslint/explicit-module-boundary-types)
  get results() {
    // Property 'rentals' does not exist on type '{}'.
    // Property 'query' does not exist on type '{}'.
    let { rentals, query } = this.args;

    if (query) {
      // Parameter 'rental' implicitly has an 'any' type.
      rentals = rentals.filter((rental) => rental.title.includes(query));
    }

    return rentals;
  }
}

The first type-checking error is the linter reminding us to add a return type to the function. From reading the code, it looks like we are expecting this function to return an array of Rental models, so let's put that for now:

// app/components/rentals/filter.ts

import Component from '@glimmer/component';
import RentalModel from 'super-rentals/models/rental';

export default class RentalsFilterComponent extends Component {
  get results(): Array<RentalModel> {
    // Property 'rentals' does not exist on type '{}'.
    // Property 'query' does not exist on type '{}'.
    let { rentals, query } = this.args;

    if (query) {
      // Parameter 'rental' implicitly has an 'any' type.
      rentals = rentals.filter((rental) => rental.title.includes(query));
    }

    return rentals;
  }
}

Alright! Next type-checking error:

Property 'rentals' does not exist on type '{}'.

We are destructuring the component args, but it looks like TypeScript has no idea what properties the args object should have. We have to tell TypeScript what the component arguments are.

Fortunately, the Glimmer Component type is a generic. It takes an optional type argument where you can specify the "component signature" as defined by Ember RFC-748 and polyfilled by Glint. To do this, we'll define an interface called RentalsFilterSignature with a field called Args to specify the component arguments. We'll mark the types for the arguments as unknown for now. Then, we can pass that interface as an argument to the Component type: Component<RentalsFilterSignature>.

// app/components/rentals/filter.ts

import Component from '@glimmer/component';
import RentalModel from 'super-rentals/models/rental';

interface RentalsFilterSignature {
  Args: {
    rentals: unknown;
    query: unknown;
  };
}

export default class RentalsFilterComponent extends Component<RentalsFilterSignature> {
  get results(): Array<RentalModel> {
    let { rentals, query } = this.args;

    if (query) {
      // rentals: Object is of type 'unknown'.
      // rental: Parameter 'rental' implicitly has an 'any' type.
      rentals = rentals.filter((rental) => rental.title.includes(query));
    }

    // Type 'unknown' is not assignable to type 'RentalModel[]'.
    return rentals;
  }
}

Now, TypeScript knows about our component's arguments, but it's complaining that because the rentals type is unknown, TypeScript doesn't know what to do with the filter method. Let's resolve these by adding a type to the rentals argument.

By doing a little sleuthing, tracing the component invocations back to the route template, we discover that the rentals argument is the resolved model from the IndexRoute.

<!-- app/components/rentals.hbs -->
<!-- ... -->

<!-- @rentals is passed into Rentals::Filter in the Rentals component -->
<Rentals::Filter @rentals={{@rentals}} @query={{this.query}} as |results|>
  <!-- ... -->
</Rentals::Filter>
<!-- app/templates/index.hbs -->
<!-- ... -->

<!-- @rentals is passed into the Rentals component in the Index Route -->
<Rentals @rentals={{@model}} />

We can extract the resolved model type from the Index Route by using the ModelFrom utility type borrowed from the ember-cli-typescript documentation cookbook.

// app/components/rentals/filter.ts

import Component from '@glimmer/component';
import RentalModel from 'super-rentals/models/rental';
import IndexRoute from 'super-rentals/routes';
import { ModelFrom } from 'super-rentals/types/util';

interface RentalsFilterSignature {
  Args: {
    rentals: ModelFrom<IndexRoute>;
    query: unknown;
  };
}

export default class RentalsFilterComponent extends Component<RentalsFilterSignature> {
  get results(): Array<RentalModel> {
    let { rentals, query } = this.args;

    if (query) {
      // query: Argument of type 'unknown' is not assignable to parameter of type 'string'.
      rentals = rentals.filter((rental) => rental.title.includes(query));
    }

    // Type 'ArrayProxy<RentalModel>' is missing the following properties from
    // type 'RentalModel[]': pop, push, concat, join, and 45 more.
    return rentals;
  }
}

Updating the rentals type in the arguments interface resolves not only the type-checking error that we've been working on, but also another one further down in the code. Sweet!

Unfortunately, we have some new type-checking errors. These errors are telling us that the rentals argument returns an ArrayProxy<RentalModel> but filter is coercing it into an Array<RentalModel>, which has slightly different behavior. For example, ArrayProxy doesn't have push or pop methods like an Array does. This could cause a bug in the future! 🐛

We always want to return an Array, so we might resolve this by first converting the rentals argument to an Array before using it in the results getter:

let rentals = this.args.rentals.toArray();

Alternatively, we can convert the @rentals argument to an array in the model hook of the Index Route. Personally I prefer using this strategy when possible because:

  1. We no longer need to import DS.
  2. I find the behavior of PromiseArray to be confusing, so I prefer to convert it to an Array ASAP.

(NOTE: There are flaws with this strategy also, but I want to avoid changing the behavior from the original app, so we'll stick with this strategy for now.)

// app/routes/index.ts

import Store from '@ember-data/store';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import RentalModel from 'super-rentals/models/rental';

export default class IndexRoute extends Route {
  @service declare store: Store;

  async model(): Promise<Array<RentalModel>> {
    return (await this.store.findAll('rental')).toArray();
  }
}

And because we used the ModelFrom utility in our component arguments interface, we don't need to make any updates there because TypeScript already knows!

OK, we're down to one final type-checking error:

Argument of type 'unknown' is not assignable to parameter of type 'string'.

TypeScript is telling us that the includes method on the rental.title string expects a string to be passed to it, but we've passed an unknown. Let's find out what that query argument type actually is!

Just like with rentals, we can determine the type of query by looking at the component invocation in the Rentals component: @tracked query = '';

Alternatively, because we have noEmitOnError set to false in our tsconfig.json, we can also run our code and find the type of query via debugger or Ember Inspector.

It looks like query is a string, so we'll enter string into the Args field of our component signature.

// app/components/rentals/filter.ts

import Component from '@glimmer/component';
import RentalsComponent from 'super-rentals/components/rentals';
import RentalModel from 'super-rentals/models/rental';
import IndexRoute from 'super-rentals/routes';
import { ModelFrom } from 'super-rentals/types/util';

interface RentalsFilterSignature {
  Args: {
    rentals: ModelFrom<IndexRoute>;
    query: string;
  };
}

export default class RentalsFilterComponent extends Component<RentalsFilterSignature> {
  get results(): Array<RentalModel> {
    let { rentals, query } = this.args;

    if (query) {
      rentals = rentals.filter((rental) => rental.title.includes(query));
    }

    return rentals;
  }
}

Phew! We're done!

LET'S COMMIT!

Rentals::Filter Component: Glint Pass

Up until now, we've been relying on the TypeScript compiler—tsc—for type-checking our TypeScript files only. Let's update the Glint configuration to include the files we've converted to TypeScript so far so that we can type-check the matching globs end-to-end:

# glintrc.yml

environment: ember-loose
include:
  - 'app/models/**'
  - 'app/routes/**'
  - 'app/components/rentals/filter.*'

Now, we can run yarn glint in our terminal:

$ yarn glint

app/components/rentals/filter.hbs:1:1 - error TS2345: Argument of type '"default"' is not assignable to parameter of type 'unique symbol'.

1 {{yield this.results}}

In addition to tracking the types for the component arguments, the component signature also tracks the blocks we expect a component to yield. This (admittedly confusing) error message is telling us that we need to specify the Blocks field in our component signature so that Glint will know to expect us to yield a default block.

In our case, the Rentals::Filter component yields a block with an array of Rental models from the results getter. We can provide this information to Glint by adding the following to the component signature:

// app/components/rentals/filter.ts

// ...

interface RentalsFilterSignature {
  Args: {/* ... */};
  Blocks: { default: [results: Array<RentalModel>] };
}

export default class RentalsFilterComponent extends Component<RentalsFilterSignature> {/* ... */}

Now, whenever we invoke the Rentals::Filter component, Glint will require that we pass content to the default block. Glint will also provide type information in the invoking template about the yielded results value.

Once we've added the Blocks field, we can run yarn glint again and should see no errors.

LET'S COMMIT!

Map Component: TSC Pass

Next, let's take a look at the Map component, which displays a map of the given coordinates. First, we'll rename the file to TypeScript and take a look at the resulting type-checking errors:

// app/components/map.ts

import Component from '@glimmer/component';
import ENV from 'super-rentals/config/environment';

const MAPBOX_API = '<https://api.mapbox.com/styles/v1/mapbox/streets-v11/static>';

export default class MapComponent extends Component {
  // Missing return type on function.
  // eslint(@typescript-eslint/explicit-module-boundary-types)
  get src() {
    // Property 'lng' does not exist on type '{}'.
    // Property 'lat' does not exist on type '{}'.
    // Property 'width' does not exist on type '{}'.
    // Property 'height' does not exist on type '{}'.
    // Property 'zoom' does not exist on type '{}'.
    let { lng, lat, width, height, zoom } = this.args;

    let coordinates = `${lng},${lat},${zoom}`;
    let dimensions = `${width}x${height}`;
    let accessToken = `access_token=${this.token}`;

    return `${MAPBOX_API}/${coordinates}/${dimensions}@2x?${accessToken}`;
  }

  // Missing return type on function.
  // eslint(@typescript-eslint/explicit-module-boundary-types)
  get token() {
    return encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);
  }
}

Let's start by adding our arguments interface and resolving the return-type lints. We'll also need to add the type for MAPBOX_ACCESS_TOKEN to the config declaration file that ember-cli-typescript generated for us in our very first commit.

// app/config/environment.d.ts

export default config;

/**
 * Type declarations for
 *    import config from 'my-app/config/environment'
 */
declare const config: {
  // ...
  MAPBOX_ACCESS_TOKEN: string;
};
// app/components/map.ts

import Component from '@glimmer/component';
import ENV from 'super-rentals/config/environment';

const MAPBOX_API = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/static';

interface MapArgs {
  lng: unknown;
  lat: unknown;
  width: unknown;
  height: unknown;
  zoom: unknown;
}

export default class MapComponent extends Component<MapArgs> {
  get src(): string {
    let { lng, lat, width, height, zoom } = this.args;

    let coordinates = `${lng},${lat},${zoom}`;
    let dimensions = `${width}x${height}`;
    let accessToken = `access_token=${this.token}`;

    return `${MAPBOX_API}/${coordinates}/${dimensions}@2x?${accessToken}`;
  }

  get token(): string {
    return encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);
  }
}

Look at that! All of our type-checking errors went away! For your first pass converting your app, I think it's totally fine to merge the unknown types like this. (It's way better than merging any.)

LET'S COMMIT!

Map Component: Glint Pass

Once again, we'll add this component's blob to the include key in our Glint configuration and run yarn glint.

# glintrc.yml

environment: ember-loose
include:
  # ...
  - 'app/components/map.*'
$ yarn glint

app/components/map.hbs:3:35 - error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'EmittableValue | AcceptsBlocks<{}, any>'.

3     alt="Map image at coordinates {{@lat}},{{@lng}}"
                                    ~~~~~~~~

// ...

Starting from the top, we can see that Glint is less pleased with our unknown values than straight TypeScript was. We are seeing a lot of errors along the lines of Argument of type 'unknown' is not assignable to parameter of type 'EmittableValue | AcceptsBlocks<{}, any>'.

But Glint expects html attributes to have an EmittableValue passed to them. EmittableValue is defined as SafeString | Element | string | number | boolean | null | void; Unfortunately, it doesn't know if our unknown values match this type, so we'll have to investigate the types of our arguments.

We can reverse-engineering the types from one of the invocations:

<!-- example invocation -->

<Map
  @lat={{@rental.location.lat}}
  @lng={{@rental.location.lng}}
  @zoom="9"
  @width="150"
  @height="150"
  alt="A map of {{@rental.title}}"
/>
// app/components/map.ts

// ...

interface MapSignature {
  Args: {
    lng: number;
    lat: number;
    width: string;
    height: string;
    zoom: string;
  };
}

export default class MapComponent extends Component<MapSignature> {/* ... */}

When we run yarn glint again, there is only one error remaining:

$ yarn glint

app/components/map.hbs:4:5 - error TS2345: Argument of type 'null' is not assignable to parameter of type 'Element'.

4     ...attributes

In addition to tracking your component's arguments and blocks, the component signature also keeps track of your component's "root element"—the element that receives "splattributes"— via the Element field. By default, the Element field type is set to null, indicating that ...attributes will not be allowed anywhere in the component template and invoking templates will not be allowed to pass any attributes to the component.

// app/components/map.hbs

<div class="map">
  <img
    alt="Map image at coordinates {{@lat}},{{@lng}}"
    ...attributes
    src={{this.src}}
    width={{@width}} height={{@height}}
  >
</div>

In order to specify that the Map component splats its attributes onto an <img> element, we can add the Element field to the component signature, like so:

// app/components/map.ts

// ...

interface MapSignature {
  Element: HTMLImageElement;
  Args: {/* ... */};
}

export default class MapComponent extends Component<MapSignature> {/* ... */}

Now, when we run yarn glint, we get no errors.

LET'S COMMIT!

Remaining Inner-Most-Inner-Most Leaf Components

Using what we've learned, convert the rest of the inner-most-inner-most components (components that do not invoke any other components):

Rentals Component: TSC Pass

Our last un-converted component is the Rentals component, which takes an array of Rental models, passes them into the Rentals::Filter component along with a filter query, then renders a Rental component for each of the yielded results.

When we rename the file, we see no type-checking errors because the class itself is pretty simple:

// app/components/rental.ts

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class RentalsComponent extends Component {
  @tracked query = '';
}

LET'S COMMIT!

Rentals Component: Glint Pass

Next, we enable Glint for the component:

# .glintrc.yml

environment: ember-loose
include:
  # ...
  - 'app/components/rentals.*'

Then run yarn glint and see one error:

$ yarn glint

app/components/rentals.hbs:8:34 - error TS2339: Property 'rentals' does not exist on type 'EmptyObject'.

8     <Rentals::Filter @rentals={{@rentals}} @query={{this.query}} as |results|>
                                   ~~~~~~~

This error is telling us that Glint doesn't yet know about the rentals argument. Let's add that to our component signature. (We already know the type from our investigation for the Rentals::Filter component!)

// app/components/rentals.ts

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import IndexRoute from 'super-rentals/routes/index';
import { ModelFrom } from 'super-rentals/types/util';

interface RentalsSignature {
  Args: {
    rentals: ModelFrom<IndexRoute>;
  };
}

export default class RentalsComponent extends Component<RentalsSignature> {/* ... */}

Now, yarn glint gives us no errors!

LET'S COMMIT!

Are we done yet?

Remember that when we set up glint, we included a catch-all "template registry" so that we won't be bombarded with errors during our conversion. Now that we've converted every component with a class file, it's time to remove that catch-all. 🙈

// types/super-rentals/index.d.ts

// ...

import '@glint/environment-ember-loose';

// NOTE: This import won't be necessary after Glint 0.8
import '@glint/environment-ember-loose/native-integration';

// Delete this:
// declare module '@glint/environment-ember-loose/registry' {
//   export default interface Registry {
//     [key: string]: any;
//   }
// }

// ...

Now, let's run yarn glint again:

$ yarn glint

app/templates/rental.hbs:1:1 - error TS7053: Element implicitly has an 'any' type because expression of type '"Rental::Detailed"' can't be used to index type 'Globals'.
  Property 'Rental::Detailed' does not exist on type 'Globals'.

1 <Rental::Detailed @rental={{@model}} />
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  
// ...

We are inundated with errors following the pattern: Element implicitly has an 'any' type because expression of type 'X' can't be used to index type 'Globals'. This error means that you need to register X in your "template registry."

Template Registry

In order for Glint to be able to find the correct component (or helper or modifier) for a template invocation, we need to register each one with Glint's template registry, similar to how we need to register our models. (NOTE: once RFC-779 is implemented, you will no longer need to do this. 🙌)

Add a registration to the bottom of each component class file, like so:

// app/components/rentals/filter.ts

// ...

export default class RentalsFilterComponent extends Component<RentalsFilterArgs> { /* ... */ }

declare module '@glint/environment-ember-loose/registry' {
  export default interface Registry {
    'Rentals::Filter': typeof RentalsFilterComponent;
  }
}

Learn more about the template registry in the Glint documentation.

LET'S COMMIT!

Now, when we run yarn glint most of the errors are gone. Unfortunately, we still have an error of this type for each template-only component. 😢

Registering Template-Only Components

When we registered our components earlier, we only registered the components that already had classes. Glint requires that you also register template-only components so that there are no gaps in the type-checking.

The Glint team recommends adding a TypeScript file for the component with import templateOnlyComponent from @ember/component/template-only; and adding the relevant boilerplate as shown in the Glint documentation.

At Tilde, we prefer to add an empty Glimmer component class. 😬 While there are minor performance gains from using template-only components, we find that these are outweighed by the cost of having to teach developers the templateOnlyComponent function, which was always meant to be an intimate API. Additionally, we found that eventually we were adding backing classes to many template-only components anyway, and the churn necessary to re-write the component file when this happened was annoying.

So, pick the method that works the best for your team and app. With that said, once RFC-779 is implemented, you will no longer need to worry about this issue. 🙌 🙌

Let's go ahead and add empty component classes and their relevant component signatures for our template-only components. Here's the Rental component as an example:

// app/components/rental.ts

import Component from '@glimmer/component';
import RentalModel from 'super-rentals/models/rental';

interface RentalSignature {
  Args: { rental: RentalModel };
}

export default class RentalComponent extends Component<RentalSignature> {}

declare module '@glint/environment-ember-loose/registry' {
  export default interface Registry {
    Rental: typeof RentalComponent;
  }
}

We can also now remove the include config from our .glintrc.yml.

And now, when we run yarn glint, there are no errors! We're done converting our app! 🎉

LET'S COMMIT!

Continuous Integration

Last but not least, we should enable type-checking in CI so that we won't accidentally merge future code with TypeScript/Glint errors:

// package.json

{
  "name": "super-rentals",
  // ...
  "scripts": {
    "lint:glint": "glint",
    // ...
  },
  // ...
}

Because Ember's default lint script aggregates all of the other lint:xxx scripts, our lint:glint script should be included when lint is run from now on, including on CI.

LET'S COMMIT!

Wrapping Up

Thanks for following along with this tutorial. If you want to see the full diff for the conversion, head on over to the PR here:

Convert to TypeScript (2022 Edition) by gitKrystan · Pull Request #1 · gitKrystan/super-rentals-ts-2022
Converting the Ember Super Rentals app to TypeScript (2022 Edition) - Convert to TypeScript (2022 Edition) by gitKrystan · Pull Request #1 · gitKrystan/super-rentals-ts-2022

If you have any questions or comments about this tutorial, feel free to comment on the PR!

FAQs

What if I want to write new code in TypeScript?

Unfortunately, the blueprints that ship with ember-cli-typescript are perpetually out-of-date with the latest ember-source blueprints. In fact, I recommend removing the ember-cli-typescript-blueprints package from your project altogether.

With that said, thanks to this PR, if you are running ember-cli >= 4.3 and ember-source >= 4.4 (now in beta), you can generate TypeScript code using Ember's built-in blueprints by running, for example, EMBER_TYPESCRIPT_BLUEPRINTS=true ember g component example -gc --typescript

Eventually the EMBER_TYPESCRIPT_BLUEPRINTS flag will no longer be necessary. Also, you will be able to set "isTypeScriptProject": true in .ember-cli to make the --typescript flag your default.

I'm really excited about the implications of RFC-779 for TypeScript users. Is there a way I can try it?

Ember RFC-779 introduces a new .gts file type that allows you to write <template> tags in your TypeScript code instead of a separate .hbs template file. Additionally, instead of using a runtime resolution strategy that looks up your components, etc, using magic strings, your <template> code will have access to values in your JavaScript scope, which means they can just use normal JavaScript imports. This means you will no longer have to add that annoying boilerplate "template registry" code at the end of all you component, modifier, and helper files. Additionally, template-only components will no longer be a special case requiring the additional thought we put into them above.

If you're excited to try it, you can! Just install ember-template-imports and have fun removing all of that annoying boilerplate.

Alternatively, check out the source for ember-wordle, which uses ember-template-imports and the <template> tag. 🤯

Do you have any sweet tricks for type narrowing in Ember?

Why, yes! Yes I do! You may already be familiar with Ember debug's assert. This function will throw an error with the provided message if the provided condition is false. Also, the type for assert is written such that TypeScript now knows that the condition must be true for all of the following lines. This allows us to use assert for type "narrowing". Also, the best part is that the assert code and its condition are stripped from your production builds, so you haven't added any production overhead. Sweet!

How can I use TypeScript with Ember Concurrency?

Picture a simple Ember Concurrency task. When you perform the waitASecond task, it waits for a second, then logs 'done' to the console.

// my-app/components/waiter.js

import { action } from '@ember/object';
import Component from '@glimmer/component';
import { task, timeout } from 'ember-concurrency';

export default class Waiter extends Component {
  @task *waitASecond() {
    yield timeout(1000);
    console.log('done');
  }

  @action startWaiting() {
    this.waitASecond.perform();
  }
}

Unfortunately, TypeScript doesn't know much about what the generator is doing to the waitASecond generator function, so it doesn't know that waitASecond is actually a Task that you can call perform on. To use Ember Concurrency with TypeScript, we need to use an add-on called ember-concurrency-ts, which gives us a taskFor method that casts the TaskGenerator as a Task:

// my-app/components/waiter.ts

import { action } from '@ember/object';
import Component from '@glimmer/component';
import { task, timeout, TaskGenerator } from 'ember-concurrency';
import { taskFor } from 'ember-concurrency-ts';

export default class Waiter extends Component {
  @task *waitASecond(): TaskGenerator<void> {
    yield timeout(1000);
    console.log('done');
  }

  @action startWaiting(): void {
    taskFor(this.waitASecond).perform();
  }
}

How can I use TypeScript with Mirage?

One of the biggest sticking points we had with TypeScript conversion was converting test files that use Mirage.

MirageJS, which powers ember-cli-mirage, does have types, but we ran into issues using them with ember-cli-mirage without lots of really complicated gymnastics that won't fit in this blog post. To that end, I am posting a GitHub gist with our gymnastics, which will hopefully be helpful to you. (NOTE: If you are a TypeScript beginner, it's OK to be overwhelmed reading the types in that gist. It was certainly overwhelming writing them! ❤️)

What if I have deeply nested gets?

Very occasionally, you still need to use get (for proxies), even with Ember Octane. If your get call is accessing a deeply nested property, however, you will need to chain your get calls together. This is because TypeScript doesn't know to split the string lookup on dots. In practice, I haven't found that this comes up super often, and often, you don't actually need get for the entire chain.

// This gives you a confusing type-checking error:
myEmberObject.get('deeply.nested.thing');

// Do one of these instead:
myEmberObject.get('deeply').get('nested').get('thing');
myEmberObject.deeply?.nested?.get('thing');
myEmberObject.deeply?.get('nested').thing;

TypeScript Without TypeScript

No appetite for switching? You can get some of TypeScript's benefits—such as code completion and documentation-on-hover—by using JSDoc annotations in your JavaScript along with the VSCode text editor. JSDoc allows you to document types, though it doesn't have all of TypeScript's features.

VSCode's JavaScript IntelliSense features are powered by the TypeScript compiler under the hood, so you even get access to TypeScript's built-in types and @types packages for your libraries, even if you don't use JSDoc annotations.

Once you've documented the types in your JavaScript files, you can even add a @ts-check comment to the top of your file to get type checking in your JavaScript files, powered by TypeScript!

Moving on

In the next article, we'll talk about the benefits of TypeScript in the context of developer confidence.