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: Introduction to Super Rentals
- Setup: Configuring TypeScript, Glint, and TypeScript ESLint
- Iterative Migration: Strategies for gradual typing
- Converting Models
- Converting Routes
- Converting Components
- Continuous Integration: Enabling Type-Checking in CI
- Wrapping Up: See the entire conversion in one PR
- FAQs
- TypeScript Without TypeScript: TS benefits for JS users
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.
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.)
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,
// ...
}
}
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 asunknown
. - The
any
type—Opt out of type checking for a value by annotating it asany
. - The
@ts-expect-error
/@glint-expect-error
directive—A better strategy thanany
, 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 typeany
. 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!
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. 😛)
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:
- For the first pass, we will address basic issues that one would find with
tsc
alone. - 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:
- We no longer need to import
DS
. - I find the behavior of
PromiseArray
to be confusing, so I prefer to convert it to anArray
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!
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.
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
.)
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.
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):
ShareButton
: tsc pass, glint passRental::Image
: tsc pass, glint pass
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 = '';
}
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!
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.
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! 🎉
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.
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:
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.