Typed Ember extends Confidence Part 2: Converting Your Ember App to TypeScript
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.
I've updated this tutorial in 2022 based on the latest and greatest. I recommend reading that version instead!
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.
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
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.
$ 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 (spoiler alert: yet), 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.
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
directive—A better strategy than any
, however, is to mark offending parts of your code with a @ts-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.)
Gradual Strictness, or How I Learned to Stop Worrying and Love the Strictness
You can also gradually increase TypeScript's strictness, as we mentioned before. 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,
// ...
}
}
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. I'm going to also add the typescript-eslint
plugin, which adds additional checks:
yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin
(👋👋 NOTE: Plus some boilerplate.)
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 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. The Rental 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."
// 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';
}
}
}
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:
// 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 { inject as 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 { inject as 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 { inject as 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 { inject as 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 { inject as service } from '@ember/service';
import Store from '@ember-data/store';
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
because PromiseArray
is private so the type isn't exported. 🤷♀️)
See also: Converting the Rental Route.
Components
Next, let's try converting a component, the inner-most leaf of our app.
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 your args. First, we'll define an interface called RentalsFilterArgs
. 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<RentalsFilterArgs>
.
// app/components/rentals/filter.ts
import Component from '@glimmer/component';
import RentalModel from 'super-rentals/models/rental';
interface RentalsFilterArgs {
rentals: unknown;
query: unknown;
}
export default class RentalsFilterComponent extends Component<RentalsFilterArgs> {
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 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 RentalsFilterArgs {
rentals: ModelFrom<IndexRoute>;
query: unknown;
}
export default class RentalsFilterComponent extends Component<RentalsFilterArgs> {
get results(): Array<RentalModel> {
let { rentals, query } = this.args;
if (query) {
// Type 'RentalModel[]' is missing the following properties from type
// 'ArrayProxy<RentalModel>': content, objectAtContent, _super, init, and
// 5 more.
// 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 19 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!
By extracting the type rather than just hard-coding it to what we think it will be, we can avoid two issues:
- Our assumption might not be correct, and because TypeScript isn't checking our templates, it won't know. This bug in our types could potentially proliferate throughout our app and cause problems when we ship our code.
- The resolved model's type might change. If we've hard-coded the type in our arguments interface, TypeScript won't be able to help us find all of the places where we are using the
model
's value to update them to match the change.
What it boils down to is: we always want our answer key to have accurate and up-to-date answers so that we can trust it.
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 can resolve this by first converting the rentals
argument to an Array
before using it.
// 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 RentalsFilterArgs {
rentals: ModelFrom<IndexRoute>;
query: unknown;
}
export default class RentalsFilterComponent extends Component<RentalsFilterArgs> {
get results(): Array<RentalModel> {
let { query } = this.args;
let rentals = this.args.rentals.toArray();
if (query) {
// Argument of type 'unknown' is not assignable to parameter of type 'string'.
rentals = rentals.filter((rental) => rental.title.includes(query));
}
return rentals;
}
}
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 want to extract the type from the calling component if possible. In this case, query
is a tracked property on that component. We can get the type of that property by importing the RentalsComponent
type and looking-up the type of the query
property using a similar syntax to what we'd use to access a value on an object. (👋👋 NOTE: For TypeScript to compile, you'll also need to convert the Rentals component to TypeScript if you are following along.)
// 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 RentalsFilterArgs {
rentals: ModelFrom<IndexRoute>;
query: RentalsComponent['query'];
}
export default class RentalsFilterComponent extends Component<RentalsFilterArgs> {
get results(): Array<RentalModel> {
let { query } = this.args;
let rentals = this.args.rentals.toArray();
if (query) {
rentals = rentals.filter((rental) => rental.title.includes(query));
}
return rentals;
}
}
Phew! We're done!
Let's take a look at one more component. 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..
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
.)
But I have a few more things I want to show you, so we'll add the real types now.
In this case, we expect this component to be re-used all over our app, so we don't want to extract the argument types from the caller like we did for the Rentals::Filter
component. Instead, we'll hard-code the types in the interface by 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
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: number;
lat: number;
width: string;
height: string;
zoom: string;
}
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);
}
}
With the actual types, we still don't have any type-checking errors. All good!
But I wonder if there is anything we can do to make this component easier to reuse. For example, is there a way to throw an error any time an engineer forgets to pass in one of these arguments?
It turns out, there is. We can use a union type to tell TypeScript that the longitude and latitude arguments might be undefined
. Then, we can use getters to alias the arguments, but with an added check using Ember debug's assert
. The assertion will throw an error with the provided message if the 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 drop the undefined
type from the argument before returning it as a number
. Thus, we can use this.lng
and this.lat
elsewhere in our code without having to worry about the possibility of them being undefined. 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!
// app/components/map.ts
import { assert } from '@ember/debug';
import Component from '@glimmer/component';
import ENV from 'super-entals/config/environment';
const MAPBOX_API = '<https://api.mapbox.com/styles/v1/mapbox/streets-v11/static>';
interface MapArgs {
lng: number | undefined;
lat: number | undefined;
width: string;
height: string;
zoom: string;
}
export default class MapComponent extends Component<MapArgs> {
get lng(): number {
assert('Please provide `lng` arg', this.args.lng);
return this.args.lng;
}
get lat(): number {
assert('Please provide `lat` arg', this.args.lat);
return this.args.lat;
}
get src(): string {
let { width, height, zoom } = this.args;
let { lng, lat } = this;
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);
}
}
See also: Converting the Rental::Image component to TypeScript.
See also: Converting the Rentals component to TypeScript.
See also: Converting the ShareButton component to TypeScript.
Alright! We're done converting our app!
Advanced Ember TypeScript Tidbits
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;
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();
}
}
Typed Templates
You can try typed templates using the els-addon-typed-templates add-on in conjunction with the Unstable Ember Language Server and/or ember-template-lint-typed-templates. It's a pretty neat little add-on that will type-check your templates, including component invocations. Admittedly, it feels like a work-in-progress, but we did find several bugs in our app when we turned it on.
One important gotcha we've found with this add-on is that when you change something in a TypeScript file, you need to "tickle" the relevant template file (e.g. by adding then removing a line) to get the add-on to re-check it.
Alternatively, you can try Glint, a new typed-template solution by members of the ember-cli-typescript team. I haven't tried it yet, but I'm sure it's awesome!
Mirage Types
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! ❤️)
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 documentation 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 features are powered by the TypeScript compiler under the hood, so you even get access to TypeScript's built-in types. You can also add @types
packages from Definitely Typed, and VSCode will use those types as well.
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.