Hone logo
Hone
Problems

Typed Provide/Inject in Vue 3 with TypeScript

Vue 3's provide and inject are powerful tools for passing data down the component tree without prop drilling. However, without proper typing, it's easy to introduce runtime errors if the injected value's type doesn't match what's expected. This challenge focuses on implementing a type-safe provide and inject mechanism in Vue 3 using TypeScript.

Problem Description

Your task is to create a reusable utility that allows you to provide a value of a specific type and inject it into a descendant component, ensuring type safety throughout the process. This utility should leverage TypeScript's type system to catch potential type mismatches at compile time, rather than at runtime.

Key Requirements:

  1. Type Safety: The provide and inject operations must be type-safe. When injecting a value, TypeScript should infer or enforce the correct type.
  2. Reusability: The solution should be encapsulated in a way that can be easily reused across different parts of your Vue application.
  3. Vue 3 Compatibility: The solution must work with Vue 3's Composition API (provide and inject functions).
  4. Optional Values: The inject function should support providing a default value or allowing the injected value to be undefined if it's not provided.

Expected Behavior:

  • A parent component uses the utility to provide a value.
  • A child component (or any descendant) uses the utility to inject that value.
  • If the injected value's type is used in a way that's incompatible with its provided type, TypeScript should flag it as a compilation error.
  • If the injected value is not provided by any ancestor and no default is given, the injected variable should be undefined (or the specified default).

Examples

Example 1: Basic Typed Provide/Inject

Let's imagine we want to provide a user object down the tree.

// Parent Component (e.g., App.vue)
import { defineComponent, provide } from 'vue';
import UserProfile from './UserProfile.vue';

interface User {
  id: number;
  name: string;
}

const USER_KEY = Symbol('user'); // Using a Symbol is good practice for keys

export function provideUser(user: User) {
  provide(USER_KEY, user);
}

export function injectUser(): User | undefined {
  return inject(USER_KEY) as User | undefined; // Type assertion here
}

export default defineComponent({
  setup() {
    const currentUser: User = { id: 1, name: 'Alice' };
    provideUser(currentUser); // Providing the user

    return {
      // ...
    };
  },
  components: {
    UserProfile,
  },
});

// Child Component (e.g., UserProfile.vue)
import { defineComponent } from 'vue';
import { injectUser } from './App.vue'; // Assuming provideUser/injectUser are exported from App.vue or a shared module

export default defineComponent({
  setup() {
    const user = injectUser(); // Injecting the user

    if (user) {
      console.log(`User ID: ${user.id}, Name: ${user.name}`); // Type-safe access
    } else {
      console.log('User not found.');
    }

    return {
      user,
    };
  },
});

Explanation:

  • provideUser takes a User object and provides it using a Symbol key.
  • injectUser attempts to inject the value associated with USER_KEY and asserts its type.
  • In UserProfile, injectUser() returns a User | undefined. Accessing user.id or user.name is type-safe. If user is undefined, accessing properties would be an error unless explicitly handled (as done with the if (user) check).

Example 2: Injecting with a Default Value

What if the user might not be provided, and we want a fallback?

// Parent Component (similar to Example 1, but might not provide user)

// Child Component (e.g., UserProfile.vue)
import { defineComponent, inject } from 'vue';

interface User {
  id: number;
  name: string;
}

const USER_KEY = Symbol('user');

// Re-defining injectUser for clarity in this example
export function injectUserWithDefault(): User {
  const defaultValue: User = { id: 0, name: 'Guest' };
  return inject(USER_KEY, defaultValue) as User; // Providing a default value
}

export default defineComponent({
  setup() {
    const user = injectUserWithDefault();

    console.log(`Displaying user: ${user.name}`); // Always has a name, even if it's 'Guest'

    return {
      user,
    };
  },
});

Explanation:

  • injectUserWithDefault uses the second argument of inject to provide defaultValue.
  • The return type of injectUserWithDefault is strictly User, as a default is guaranteed.

Example 3: Incorrect Type Injection (Compile-Time Error)

// Parent Component (provides a string, not a number)
import { defineComponent, provide } from 'vue';
import ChildComponent from './ChildComponent.vue';

const SETTINGS_KEY = Symbol('settings');

export function provideSettings(settings: string) {
  provide(SETTINGS_KEY, settings);
}

export default defineComponent({
  setup() {
    provideSettings('dark');
    return {};
  },
  components: { ChildComponent },
});

// Child Component
import { defineComponent, inject } from 'vue';

const SETTINGS_KEY = Symbol('settings'); // Must match the key used in parent

export function injectSettings(): number | undefined { // Expecting a number
  return inject(SETTINGS_KEY) as number | undefined;
}

export default defineComponent({
  setup() {
    const timeout = injectSettings(); // Trying to inject a number

    // This line would cause a TypeScript compilation error:
    // Type 'number | undefined' is not assignable to type 'number'.
    // Type 'undefined' is not assignable to type 'number'.
    // If timeout is actually a string, this would be a runtime error.
    // const processedTimeout = timeout * 1000;

    return {
      timeout,
    };
  },
});

Explanation:

  • The injectSettings function is typed to return number | undefined, but the provideSettings in the parent actually provides a string.
  • TypeScript will flag the attempt to use timeout as a number (e.g., in a calculation) as a compilation error.

Constraints

  • The solution should be implemented using Vue 3's Composition API.
  • The solution must be written in TypeScript.
  • The utility should be generic enough to work with any data type that can be provided and injected.
  • The injection mechanism should allow for the possibility of the injected value being undefined.

Notes

Consider how to manage your injection keys. Using Symbol is generally recommended to avoid naming collisions. Think about how to create a strongly typed wrapper around provide and inject that handles the key management and type assertions elegantly. You might explore creating a generic factory function for your typed provide and inject pairs.

Loading editor...
typescript