This is a short article on what you can do with provide/inject TypeScript support in Vue 3 and @vue/composition-api library for Vue 2.

Providing Type Safety

Initially when I started using provide/inject with the composition API I wrote code that looked like this:

tsimport { inject } from 'vue';
import { Product } from '@/types';

export default {
  setup() {
    const product = inject('product') as Product;
  },
};

Do you spot the problem?

When working with TypeScript, I view the as keyword like an escape hatch where you don't know how to make TypeScript understand the type of the thing you are dealing with. And while I still use it occasionally, I like to avoid using as as much as possible (pun intended).

For context, I tweeted about this very topic some time ago, I was using the composition API in a production TypeScript app and needed to provide type information for provide/inject and I was going to do that myself but found that Vue already has a utility type called InjectionKey<T> which did exactly what I needed.

That means you need to have a special constants file holding the Injectable keys, then you can use the InjectionKey<T> type to create symbols that hold the resolved injected property type information.

ts// types.ts
interface Product {
  name: string;
  price: number;
}

// symbols.ts
import { InjectionKey } from 'vue';
import { Product } from '@/types';

const ProductKey: InjectionKey<Product> = Symbol('Product');

The great thing about the InjectionKey type is that it works in both directions. It provides type safety to provide which means if you try to provide an incompatible value with that key, TypeScript will complain:

tsimport { provide } from 'vue';
import { ProductKey } from '@/symbols';

// ⛔️ Argument of type 'string' is not assignable to ...
provide(ProductKey, 'this will not work');

// ✅
provide(ProductKey, {
  name: 'Amazing T-Shirt',
  price: 100,
});

And on the receiving end, your inject will also be typed correctly:

tsimport { inject } from 'vue';
import { ProductKey } from '@/symbols';

const product = inject(ProductKey); // typed as Product or undefined

One thing to note is that the inject function produces the resolved type in union with undefined. This is because there is the possibility that the injection is not resolved. It's up to you how you want to handle it.

To get rid of undefined you need to pass a fallback value to the inject function. What's cool here, is that the fallback value is also type checked:

tsimport { inject } from 'vue';
import { ProductKey } from '@/symbols';

// ⛔️ Argument of type 'string' is not assignable to ...
const product = inject(ProductKey, 'nope');

// ✅ Type checks out
const product = inject(ProductKey, { name: '', price: 0 });

Providing Reactive Values

While you can provide plain value types, they are not that useful as usually you need to react to these values changing. You can create reactive injections as well with generic types.

For reactive refs created with ref you can use the generic Ref type to type your InjectionKey, so it's a nested generic type:

js// types.ts
interface Product {
  name: string;
  price: number;
}

// symbols.ts
import { InjectionKey, Ref } from 'vue';
import { Product } from '@/types';

const ProductKey: InjectionKey<Ref<Product>> = Symbol('Product');

Now when you inject the ProductKey into your components, the resolved property will have the Ref<Product> type or undefined like we discussed earlier:

tsimport { inject } from 'vue';
import { ProductKey } from '@/symbols';

const product = inject(ProductKey); // typed as Ref<Product> | undefined

product?.value; // typed as Product

Dealing with undefined

I already mentioned how to deal with undefined being resolved with your plain values, but for complex objects and reactive objects you cannot really offer a safe fallback value.

In our example we tried resolving a Product context object, if that injection doesn't exist then maybe there are a more severe underlying issue that justifies failing with an error if the injection is not found.

By default Vue displays a warning if it did not resolve an injection, Vue could've chosen to throw errors if an injection is not found but Vue can't really make the assumption about whether the injection is required or not, so it's up to you to make sense of unresolved injection and the undefined value.

For optional injected properties, just provide a fallback value or you can use the optional chaining operator if its not that important:

tsimport { inject } from 'vue';
import { CurrencyKey } from '@/symbols';

const currency = inject(CurrencyKey, ref('$'));
currency.value; // no undefined

// or
const currency = inject(CurrencyKey);
currency?.value;

But for required ones like our Product type, we can do something as simple as this:

tsimport { inject } from 'vue';
import { ProductKey } from '@/symbols';

const product = inject(ProductKey);
if (!product) {
  throw new Error(`Could not resolve ${ProductKey.description}`);
}

product.value; // typed as `Ref<Product>`

Throwing errors is one way to take advantage of TypeScript's exhaustive checking feature. Because we handled the undefined component early on, there is no way for the code to reach the last line without the product being actually resolved.

Let's make this more re-usable, let's create a function called injectStrict that does all of that for us:

tsfunction injectStrict<T>(key: InjectionKey<T>, fallback?: T) {
  const resolved = inject(key, fallback);
  if (!resolved) {
    throw new Error(`Could not resolve ${key.description}`);
  }

  return resolved;
}

And now if you use that instead of inject, you will get the same safety in a modular way without having to deal with pesky undefined:

tsimport { injectStrict } from '@/utils';

const product = injectStrict(ProductKey);

product.value; // typed as `Product`

Conclusion

I think provide/inject will surge in popularity especially with the composition API, and knowing the TypeScript capabilities of them will make your code easier to maintain and much safer to work with.

Thanks for reading! 👋