typescript ember development tutorial opinion

Why I Finally Stopped Fighting TypeScript (And You Should Too)

I was wrong about TypeScript. Not a little wrong. Completely, stubbornly wrong.

For years I watched the TypeScript hype train roll through the JavaScript ecosystem. I ignored it. I had my reasons: extra build complexity, type gymnastics, that smug feeling you get from TypeScript evangelists. I was perfectly happy writing JavaScript, thank you very much.

But I love types. Swift is my happy place. Opaque return types that let you hide implementation details while maintaining type safety. Actor types that make concurrent code actually understandable. TypeScript felt like a downgrade by comparison. None of that elegant abstraction, just any and unknown and hoping for the best.

I resisted TypeScript not because I hate types, but because I'd seen what good types look like and TypeScript felt like settling.

Then .gts files changed everything.

The .gts Epiphany

Here's what broke through my resistance: seeing template code and component logic in the same file, with real type-checking across both. Not bolted-on types. Not afterthought types. Types that actually understood what {{@model.title}} meant.

interface BlogPostSignature {
  Args: {
    model: BlogPost | null;
  };
}

export default class BlogPostTemplate extends Component<BlogPostSignature> {
  <template>
    {{#if @model}}
      <h1>{{@model.title}}</h1>
      <time>{{@model.date}}</time>
    {{/if}}
  </template>
}

Your editor knows what @model is. It knows what properties it has. Typo @model.titel and you get a squiggly line before you even save. This isn't the TypeScript I was avoiding. This is just better code.


What Changed My Mind

Templates and logic in one place. That's what did it.

No more bouncing between .hbs and .ts files. No mental overhead keeping track of which arguments a component expects. Open the .gts file, see everything.

The compiler actually understands {{@post.title}}. Not through some hacky string parser. Through Glint, which extends TypeScript to understand Ember's template syntax. Rename a service method and the compiler tells you every place it's used, including in templates. Change a component's signature and every place you invoke it lights up with errors.

You stop being afraid to touch code.


A Real Example: Service Injection Done Right

Here's how you inject a typed service in a component:

// app/services/session.ts
import Service from '@ember/service';
import type { User } from 'my-app/models/user';

export default class SessionService extends Service {
  currentUser?: User;
  
  setCurrentUser(user: User) {
    this.currentUser = user;
  }
}

Now when you inject it in a component:

import Component from '@glimmer/component';
import { service } from '@ember/service';
import type SessionService from 'my-app/services/session';

export default class AppHeader extends Component {
  @service declare session: SessionService;
  
  <template>
    <header>
      {{this.session.currentUser.name}}
    </header>
  </template>
}

Type this.session. and autocomplete kicks in. Call a method that doesn't exist and you get an error immediately. The feedback is instant.

This is what made me convert. Not the promise of fewer bugs (though that's real). The immediate, continuous feedback loop.


Component Signatures: The Good Part

Component signatures felt like ceremony when I first saw them. Another TypeScript hoop to jump through. Then I actually used them.

interface InviteModalSignature {
  Args: {
    show: boolean;
    user: User;
    onClose: () => void;
    onSuccess: () => void;
  };
}

export default class InviteModal extends Component<InviteModalSignature> {
  // Now @show, @user, @onClose, @onSuccess are all typed
  
  handleClose = () => {
    this.args.onClose(); // TypeScript knows this exists and what it does
  };
  
  <template>
    <Modal @isOpen={{@show}}>
      {{! TypeScript knows @show is a boolean }}
    </Modal>
  </template>
}

The signature is documentation that can't lie. You can't pass a string where a boolean is expected. You can't forget a required argument. Your future self (or your coworker) opens this component and knows exactly what it needs.


Tracked Properties: State You Can Trust

type FormData = {
  firstName: string;
  lastName: string;
  email: string;
};

type ModalStep = 'form' | 'confirm' | 'success';

export default class InviteModal extends Component<InviteModalSignature> {
  @tracked formData: FormData = {
    firstName: '',
    lastName: '',
    email: '',
  };
  
  @tracked currentStep: ModalStep = 'form';
  @tracked isSubmitting = false;
}

Define the type once. TypeScript enforces it everywhere. Try to set this.currentStep = 'random-string' and get an immediate error.


Form Validation With Valibot

Runtime validation and compile-time types usually drift apart. You write a Zod schema, then separately define a TypeScript type, and eventually they disagree about whether email is required.

Valibot solves this:

import * as v from 'valibot';

const formSchema = v.object({
  firstName: v.pipe(v.string('First Name is Required'), v.nonEmpty('First Name is Required')),
  lastName: v.pipe(v.string('Last Name is Required'), v.nonEmpty('Last Name is Required')),
  email: v.pipe(v.string('Invalid Email Address'), v.email('Invalid Email Address')),
});

type FormData = v.InferOutput<typeof formSchema>;

The schema is the type. Change the validation rules and the TypeScript type updates automatically. No drift.


Setting Up TypeScript in Ember (The Actual Steps)

If you're starting fresh or adding TypeScript to an existing Ember app:

New App with TypeScript

npm init ember-app@latest my-app -- --typescript

That's it. The blueprint sets up everything: TypeScript, Glint v2 (via ember-tsc), proper tsconfig. You're ready to write .gts files immediately.

Install Glint v2 (ember-tsc)

pnpm add -D @glint/ember-tsc @glint/template

Then update your package.json scripts:

{
  "scripts": {
    "lint:types": "ember-tsc --noEmit"
  }
}

The --noEmit flag means TypeScript only type-checks without generating output files (Vite handles the build).

Configure tsconfig.json

Your tsconfig.json should extend the Ember defaults:

{
  "extends": "@ember/app-tsconfig",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

No special Glint configuration block needed for v2. It just works.

VS Code Extension

Install the Glint v2 extension from the VS Code marketplace (look for the orange icon). This gives you inline type-checking, autocomplete, and go-to-definition in your templates.

Your First .gts File

Create a component:

// app/components/hello.gts
import Component from '@glimmer/component';

interface HelloSignature {
  Args: {
    name: string;
  };
}

export default class Hello extends Component<HelloSignature> {
  <template>
    <div>
      <h1>Hello, {{@name}}!</h1>
    </div>
  </template>
}

Run pnpm lint:types and watch TypeScript validate your component. Change {{@name}} to {{@notAValidArg}} and see it fail.

That's the feedback loop.


Getting Started

Don't migrate everything at once. Write one new component as .gts. See how it feels. Add types to your most-used service. Watch the autocomplete kick in.

After a week of that, you won't want to go back.


What I Was Wrong About

"TypeScript adds too much complexity"

It adds some complexity. But it removes more. The complexity of debugging undefined is not a function. The complexity of grep-ing the codebase to find where a function is called. The complexity of refactoring and hoping you got everything.

"The learning curve is too steep"

For advanced types? Sure. But you don't need advanced types. You need @service declare myService: MyService and interface MyComponentSignature. That's maybe an hour of learning.

"It's just busywork for the compiler"

The compiler is working for you. Every error it catches is time saved. Every autocomplete suggestion is context you didn't have to hold in your head.


What Actually Convinced Me

Not blog posts. Not conference talks. Actual code.

I added types to one service. Got autocomplete. Renamed a method and watched the compiler show me every place it was used. Made a change confidently instead of grep-ing and hoping.

That's when I stopped resisting.


The AI + TypeScript Feedback Loop

Here's something I didn't expect: TypeScript catches exactly the kinds of mistakes AI makes.

AI writes fast. It generates plausible-looking code. But it hallucinates properties, forgets to import types, uses the wrong method signatures. Without TypeScript, you find these bugs at runtime. With TypeScript, tsc finds them immediately.

Ask AI to generate a component. Run pnpm lint:types. See 5 type errors. Feed the errors back. Get corrected code. Types pass, code works.

Missing import? Type error. Wrong property name? Type error. Callback with wrong signature? Type error.

Now, if you've read my Intent-Driven Development piece, you know the goal isn't to catch AI mistakes after the fact. It's to give AI enough context (through docs, patterns, structure) that it gets things right the first time. TypeScript is the safety net for when it doesn't.

Good documentation and clear patterns reduce the error rate. TypeScript catches what slips through. Together, they make AI-assisted development actually work at scale.

This is why I'm not worried about "AI-generated spaghetti code." With intent-driven structure and TypeScript enforcement, the spaghetti doesn't compile. The type system enforces the same rigor whether you're writing the code or AI is.


TypeScript vs. The Type Systems I Actually Like

TypeScript is fine for what it is: a gradual type system bolted onto JavaScript. Coming from Swift though, the gap is obvious.

Opaque types:

// Swift
func makeContainer() -> some Container {
    // Return type is hidden but compiler knows it
    return ConcreteContainer()
}

You get to hide implementation while keeping full type safety. TypeScript? You'd use an interface and lose the concrete type info, or expose everything.

Actors for concurrency:

// Swift
actor UserCache {
    private var cache: [String: User] = [:]
    
    func get(_ id: String) -> User? {
        cache[id]
    }
}

Thread-safe by default. The compiler enforces it. TypeScript has... promises and hoping you got the async right.

That said: good enough types beat no types. TypeScript in Ember might not be Swift, but it's miles better than JavaScript. The types I can get in .gts files (component signatures, typed services, typed template arguments) cover 90% of what I need. The other 10%? I can live with it.


The Bottom Line

TypeScript in Ember isn't perfect. You'll hit weird edge cases. You'll fight with the type system sometimes.

But .gts files changed the game. Templates and logic together, properly typed, with real editor support. I can't go back now. I tried, briefly, to work on an old JavaScript Ember app. It felt like driving without headlights.

Try it on one component. Just one. See if the autocomplete and error checking make a difference. If they don't, fine. I was wrong again. But I don't think I am this time.


AI Transparency: This article was written with Claude Sonnet 4.5 assistance. I provided the perspective (as someone who resisted TypeScript until .gts files), code examples from real projects, and guided the structure and voice throughout. All code examples are based on actual patterns we use in production.

If this article helped you ship code, consider sponsoring my work.

No perks. No ads. Just support for writing that doesn't waste your time.