6.6 KiB
6.6 KiB
title, impact, impactDescription, type, tags
| title | impact | impactDescription | type | tags | |||||
|---|---|---|---|---|---|---|---|---|---|
| Use InjectionKey for Type-Safe Provide/Inject | MEDIUM | Without InjectionKey, injected values are typed as unknown requiring manual type assertions | best-practice |
|
Use InjectionKey for Type-Safe Provide/Inject
Impact: MEDIUM - When using provide/inject with TypeScript, use InjectionKey<T> with Symbol to achieve type-safe dependency injection. Without it, injected values have unknown type or require manual type assertions.
Task Checklist
- Define injection keys using
Symbol() as InjectionKey<T> - Store injection keys in a shared file for provider/consumer access
- Understand that injected values are always
T | undefined - Provide default values to remove undefined from the type
The Problem
<!-- Provider.vue -->
<script setup lang="ts">
import { provide } from 'vue';
interface User {
id: string;
name: string;
}
const user: User = { id: '1', name: 'John' };
// String key - no type information
provide('user', user);
</script>
<!-- Consumer.vue -->
<script setup lang="ts">
import { inject } from 'vue';
// Type is unknown!
const user = inject('user'); // Type: unknown
// Must manually assert type - error prone
const user = inject('user') as User; // Works but risky
</script>
The Solution: InjectionKey
// keys.ts - Shared injection keys file
import type { InjectionKey, Ref } from 'vue';
export interface User {
id: string;
name: string;
}
export interface AuthContext {
user: Ref<User | null>;
login: (credentials: Credentials) => Promise<void>;
logout: () => Promise<void>;
}
// Type-safe injection keys
export const userKey: InjectionKey<User> = Symbol('user');
export const authKey: InjectionKey<AuthContext> = Symbol('auth');
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue';
import { userKey, authKey, type User, type AuthContext } from '@/keys';
const user: User = { id: '1', name: 'John' };
// Type-checked! Must provide correct type
provide(userKey, user);
// Error if type doesn't match
provide(userKey, { wrong: 'data' }); // TypeScript error!
// Complex context
const authContext: AuthContext = {
user: ref(null),
login: async (creds) => {
/* ... */
},
logout: async () => {
/* ... */
},
};
provide(authKey, authContext);
</script>
<!-- Consumer.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import { userKey, authKey } from '@/keys';
// Automatically typed as User | undefined
const user = inject(userKey);
// Type: AuthContext | undefined
const auth = inject(authKey);
// Access with proper typing
if (user) {
console.log(user.name); // TypeScript knows this is string
}
</script>
Handling the Undefined Type
Injected values are always potentially undefined because the provider might not exist:
<script setup lang="ts">
import { inject } from 'vue';
import { userKey, type User } from '@/keys';
// Option 1: Provide a default value (removes undefined)
const user = inject(userKey, { id: '0', name: 'Guest' });
// Type: User (not undefined)
// Option 2: Use factory function for default
const user = inject(userKey, () => ({ id: '0', name: 'Guest' }), true);
// Type: User (factory is called only if no provider)
// Option 3: Assert non-null when certain provider exists
const user = inject(userKey)!;
// Type: User (use sparingly!)
// Option 4: Guard with runtime check
const user = inject(userKey);
if (!user) {
throw new Error('User provider not found. Wrap component in UserProvider.');
}
// Type: User after check
</script>
Creating a useInject Composable
For cleaner consumer code, create typed composables:
// composables/useAuth.ts
import { inject } from 'vue';
import { authKey, type AuthContext } from '@/keys';
export function useAuth(): AuthContext {
const auth = inject(authKey);
if (!auth) {
throw new Error(
'useAuth() requires an AuthProvider ancestor. ' +
'Make sure to wrap your component tree with <AuthProvider>.',
);
}
return auth;
}
<!-- Consumer.vue -->
<script setup lang="ts">
import { useAuth } from '@/composables/useAuth';
// Clean API, guaranteed non-null, proper error message
const { user, login, logout } = useAuth();
</script>
Organizing Injection Keys
For larger applications, organize keys by domain:
src/
├── injection-keys/
│ ├── index.ts # Re-exports all keys
│ ├── auth.ts # Auth-related keys
│ ├── theme.ts # Theme-related keys
│ └── i18n.ts # i18n-related keys
// injection-keys/auth.ts
import type { InjectionKey, Ref } from 'vue';
export interface AuthState {
isAuthenticated: Ref<boolean>;
user: Ref<User | null>;
}
export interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
export interface AuthContext extends AuthState, AuthActions {}
export const authStateKey: InjectionKey<AuthState> = Symbol('authState');
export const authActionsKey: InjectionKey<AuthActions> = Symbol('authActions');
export const authContextKey: InjectionKey<AuthContext> = Symbol('authContext');
// injection-keys/index.ts
export * from './auth';
export * from './theme';
export * from './i18n';
Generic Injection Keys
For reusable patterns with generics:
// keys/list.ts
import type { InjectionKey, Ref } from 'vue';
export interface ListContext<T> {
items: Ref<T[]>;
selectedItem: Ref<T | null>;
selectItem: (item: T) => void;
}
// Factory function for creating typed keys
export function createListKey<T>(name: string): InjectionKey<ListContext<T>> {
return Symbol(name) as InjectionKey<ListContext<T>>;
}
// Usage
interface Product {
id: string;
name: string;
}
export const productListKey = createListKey<Product>('productList');
Best Practices Summary
- Always use InjectionKey for TypeScript projects
- Use Symbols to prevent key collisions
- Create composables like
useAuth()for clean consumer API - Throw descriptive errors when required providers are missing
- Organize keys in a shared location accessible to providers and consumers
- Provide default values when the provider is optional