In Vue 3, how do you type a cart service shared through provide/inject so the provider and every consumer agree on its type?
answer
- a symbol that carries a type
- one key in a shared module
- inject still returns undefined sometimes
- defaults and guards remove undefined
- string keys give unknown
basics
~10 sExport a typed key, const CartKey: InjectionKey<CartService> = Symbol('cart'), from one module. provide(CartKey, value) is checked against CartService, and inject(CartKey) returns CartService | undefined, which a default or a throwing useCart() helper narrows.
solid answer
~40 sDeclare the key once, in its own module: `export const CartKey: InjectionKey<CartService> = Symbol('cart')`. `InjectionKey<T>` is a symbol type that carries `T`, so `provide(CartKey, cart)` rejects a value that is not a `CartService`, and `inject(CartKey)` is typed from the key with no type argument. The result is `CartService | undefined`, because nothing guarantees an ancestor provided it; in development Vue also warns `injection "Symbol(cart)" not found.` Remove the `undefined` with a default, `inject(CartKey, fallback)`, or better, wrap the lookup in a `useCart()` composable that throws a clear error when the provider is missing and returns `CartService`. String keys lose the link: `inject('cart')` is `unknown`, and `inject<CartService>('cart')` is an unchecked claim.
go deeper
Recall that provide and inject are typed with an InjectionKey symbol exported from a shared module.
Explain why inject returns T | undefined, how defaults remove undefined, and why string keys give unknown.
Design a throwing useCart() composable so consumers get a non-optional service and misplaced components fail loudly.
Decide which app-wide services go through typed injection keys versus props or a store, and where the key modules live.
## The problem: two files, one contract `provide()` and `inject()` usually run in **different components**, often in different files: a layout provides a cart service, and a header badge, a product card and a checkout page inject it. Nothing in the call `inject('cart')` tells TypeScript what comes back, and nothing stops a provider from supplying something with a different shape. Vue's answer is **`InjectionKey<T>`**: a symbol type that carries the value type `T`, so the key itself is the shared contract. ## Declaring the key and the service ```ts // cart.ts import { inject } from 'vue' import type { ComputedRef, InjectionKey, Ref } from 'vue' export interface CartItem { sku: string; price: number; qty: number } export interface CartService { items: Ref<CartItem[]> total: ComputedRef<number> add(item: CartItem): void } export const CartKey: InjectionKey<CartService> = Symbol('cart') export function useCart(): CartService { const cart = inject(CartKey) if (!cart) { throw new Error('useCart() needs a cart provider above this component') } return cart } ``` - The key lives in **one module** that both sides import; the docs recommend a separate file for exactly this reason. - `Symbol('cart')` gives a unique key, and the description `'cart'` shows up in the development warning. ## Providing it ```vue <script setup lang="ts"> import { provide, ref, computed } from 'vue' import { CartKey, type CartItem } from './cart' const items = ref<CartItem[]>([]) const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0) ) provide(CartKey, { items, total, add(item: CartItem) { items.value.push(item) } }) </script> ``` `provide()` reads the value type out of the key, so leaving out `total` or passing a string where `CartService` is expected is a **type error in the provider**. ## What `inject()` returns | Call | Type | Why | |---|---|---| | `inject(CartKey)` | `CartService \| undefined` | No provider may exist at runtime | | `inject(CartKey, fallbackCart)` | `CartService` | The default fills the gap | | `inject('cart')` | `unknown` | A string key carries no type | | `inject<CartService>('cart')` | `CartService \| undefined` | The type argument is an unchecked assertion | | `inject('cart') as CartService` | `CartService` | A forced cast; no check at all | The `undefined` is honest: if a component using the cart is rendered outside the provider's subtree, `inject` finds nothing, returns `undefined`, and in development Vue warns `injection "Symbol(cart)" not found.` ## Handling the missing case 1. **A throwing composable** (`useCart()` above) turns a silent `undefined` into an immediate, explanatory error and gives every consumer a non-optional `CartService`. This is the usual choice for services that must exist. 2. **A default value** suits optional features: `inject(CartKey, emptyCart)` returns `CartService`. For an expensive default, pass a factory and `true` as the third argument so the factory runs only when needed. 3. **Non-null assertions** (`inject(CartKey)!`) compile but move the failure to the first property access, with a less useful error. ## Keeping consumers honest The typed key also shapes **what consumers may do** with the service. Two conventions are common with a cart: - **Expose actions, not raw state writes.** Consumers call `add(item)`; the provider owns how `items` changes. Typing `items` as a readonly view in the service interface makes accidental writes from a header badge a type error. - **Keep the interface small and stable.** Every component importing `CartKey` depends on `CartService`; adding a field is cheap, renaming one touches every consumer, which the type checker will find for you. Because both conventions live in the `CartService` interface, the provider's implementation can change freely as long as it still satisfies the type, and `provide(CartKey, ...)` will reject it the moment it does not. ## Why not string keys String keys work at runtime, but they give up everything above: any component can collide with `'cart'`, the injected type is `unknown`, and a type argument on `inject` is only a claim nobody checks against the provider. A symbol key typed with `InjectionKey<T>` makes a mismatched provider a compile error. ## Pitfalls - Declaring the key **inside a component file** and importing that component elsewhere just to get the key. - Typing the service with plain values (`items: CartItem[]`) while providing refs, so consumers lose reactivity or the types lie. - Using `inject<CartService>('cart')` and believing the provider is checked.
- In Vue 3, what does `inject(CartKey)` return and log when no ancestor provided the cart?It returns `undefined`, and in development builds Vue logs `injection "Symbol(cart)" not found.` That is why the typed result is `CartService | undefined`. With a second argument, `inject` returns the default instead and does not warn.
- In Vue 3, how do you supply an expensive default to inject without building it on every call?Pass a factory function as the default and `true` as the third argument: `inject(CartKey, () => createEmptyCart(), true)`. Vue then calls the factory only when no provider is found, and the result type is `CartService`.
InjectionKey is a labelled luggage tag: the tag states what is in the bag, the check-in desk (provide) refuses a bag that does not match the label, and whoever collects it (inject) knows the contents without opening it, though the bag may still not have arrived.
saying these in an interview costs you the question
- inject(CartKey) returns CartService because the key already guarantees a provider.
- A string key with inject<CartService>('cart') is checked against the provider.
- InjectionKey is a runtime class that validates provided values.
- Each component should create its own Symbol('cart') key.
- A non-null assertion on inject is as safe as a throwing useCart() helper.