Why are variable typing of children not passed with context?

Cover Image for Why are variable typing of children not passed with context?
Justin Bender
Justin Bender

Why doesn't variable typing of children pass with context?

Table of Contents

Understanding the problem (basic discriminated union setup with context)

TLDR: Here is the repo I'll be talking about in this article. If you'd rather look at the code.

For those that want to hear my though process. Welcome. You would sometimes assume that typing would have certain features. We are spoiled but there is one type problem. I wanted to solve for myself.

There was a task I tried 3 other times. A typing problem that has an easy solution, but builds up with complexity. Having children of a parent component using useContext. How to understand which values are accessible with a discriminated union as the type.

// Define your discriminated union type
type AuthStateProps =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "authenticated"; user: { name: string; email: string } }
  | { status: "error"; error: string };

If we take a simple discriminated union. Where the status needs to be a value and it's different for everyone. Let's build out a better understanding of the framework we are using. This is React based if you didn't understand that already. We will be using createContext and useContext functions from the framework.

That's where the problem lies. In how the context works. I'll admit right now. I don't know what the context really is. How it functions at this time. Was focused more on a solution that was stumbled upon. After this. I'll likely look back. For a way it could be cleaner.

Basic react context layout

A basic layout of how the problem looks. Where we will create the context and the function to pull out data. Using types as much as possible.

const AuthStateContext = createContext<AuthStateProps>(
  {} as AuthStateProps
);
 
export const useAuthState = () => {
  return useContext(AuthStateContext);
};
// Use in a provider
const Parent(props: AuthStateProps) {
 
  return (
    <AuthContext.Provider value={props}>
    {!!props.status === 'authenticated' ? <Profile /> : <Login />}
    </AuthContext.Provider>
  );
}

Let's look in the Profile component

function Profile() {
  // When you pass the expected discriminant value, the type is narrowed
  const auth = useAuthContext();
  /*
    auth is typed as:
    { status: 'authenticated' | 'idle' | 'loading' | 'error'; }
 
    Could also use
    const { status } = useAuthContext();
  */
 
  // This auth.user.name would error here. The context doesn't know that we are inside of the parent components logic.
  return <div>Welcome, {auth.user.name}!</div>;
}

We end up having to do some sort of guarding inside of the children component. Cause they do not understand the state above them in regards to context.

The easy solution is

const auth = useAuthContext();
const { status } = auth;
const user = "user" in auth ? auth.user : undefined;
/*
  If you're in a large component. It becomes verbose.
  
  const value1 = "value1" in auth ? auth.value1 : undefined;
  const value2 = "value2" in auth ? auth.value2 : undefined;
  const value3 = "value3" in auth ? auth.value3 : undefined;
  const value4 = "value4" in auth ? auth.value4 : undefined;
  const value5 = "value5" in auth ? auth.value5 : undefined;
  const value6 = "value6" in auth ? auth.value6 : undefined;
  const value7 = "value7" in auth ? auth.value7 : undefined;
  const value8 = "value8" in auth ? auth.value8 : undefined;
  const value9 = "value9" in auth ? auth.value9 : undefined;
  const value10 = "value10" in auth ? auth.value10 : undefined;
*/

Let's be honest. That's likely fine for most applications. There isn't much wrong with that. Until we start getting to large pages. With many different variation of internal components.

Looking at the full working code

type AuthStateProps =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "authenticated"; user: { name: string; email: string } }
  | { status: "error"; error: string };
 
const AuthStateContext = createContext<AuthStateProps>(
  {} as AuthStateProps
);
 
export const useAuthState = () => {
  return useContext(AuthStateContext);
};
// Use in a provider
const Parent(props: AuthStateProps) {
 
  return (
    <AuthContext.Provider value={props}>
    {!!props.status === 'authenticated' ? <Profile /> : <Login />}
    </AuthContext.Provider>
  );
}
 
function Profile() {
  const auth = useAuthContext();
  const { status } = auth;
  const user = "user" in auth ? auth.user : undefined;
 
  return <div>Welcome, {auth.user.name}!</div>;
}

This solution does not allow those child components to be composable. They are locked to the pages' context.

The login component would be very similar so we will focus on this single example for now.

Typing out the discriminated props for context

The solution that I want. Removes all that extra annoyance, gives error and guidance where possible. To the developers after me. Can understand how to use these. With some guardrails.

I'd prefer that it would just understand. That the component is only used inside of the parent component. Which is only allowed to render if the status === 'authenticated'. Alas, it cannot.

Can't we just update the linting rules to yell at us? Maybe?

Recently stumbled on something called overloading a hook.

What is an overloaded hook (function overloading in TypeScript)?

Function overloading in TypeScript lets you define multiple type signatures for the same function name. Each signature describes a different way to call the function with different parameter types and return types. The actual implementation comes last and must handle all the cases.

// Multiple signatures for the SAME function name - this is overloading
function useAuthContext(status: "authenticated"): {
  user: { name: string; email: string };
};
function useAuthContext(status: "error"): { error: string };
function useAuthContext(status: "loading"): Record<string, never>;
function useAuthContext(status: "idle"): Record<string, never>;
function useAuthContext(): AuthStateProps;
// The implementation signature (not visible to consumers)
function useAuthContext(status?: AuthStateProps["status"]) {
  // actual implementation here
}

Why can we have the same function name multiple times?

TypeScript distinguishes between overload signatures and the implementation signature:

  1. Overload signatures (the repeated function useAuthContext(...) lines) tell TypeScript: "When someone calls this function with these specific arguments, return this specific type."

  2. Implementation signature (the final one with the actual body) is where the logic lives. It must be compatible with ALL the overload signatures above it.

When you call the function, TypeScript looks at your arguments and matches them against the overload signatures in order from top to bottom. The first matching signature determines the return type.

This is the key insight: by passing the discriminant value ("authenticated") to the hook, TypeScript can return the exact narrowed type instead of the full union. The overloads create a mapping from input → specific output type.

Which is interesting and something I haven't used much. So let's dig into using it for this specific solution. It sounds like a perfect use of this overloading.

Inline overloading

We want to show how this could work inline for this specific function. Before we start attempting to make anything generic in nature.

It looks very similar, but it moves the ability to understand which context you are using. To the value given to the use hook.

type AuthStateProps =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "authenticated"; user: { name: string; email: string } }
  | { status: "error"; error: string };
 
const AuthContextValue = AuthStateProps;
 
const AuthContext = createContext<AuthContextValue>(
  {} as AuthContextValue,
);
 
function useAuthContext(): AuthStateProps;
function useAuthContext(status: "authenticated"): {
  user: { name: string; email: string };
};
function useAuthContext(status: "error"): { error: string };
function useAuthContext(status: "loading"): Record<string, never>;
function useAuthContext(status: "idle"): Record<string, never>;
function useAuthContext(status?: AuthStateProps["status"]) {
  const ctx = useContext(AuthContext);
  return ctx;
}
// Use in a provider
const Parent(props: AuthStateProps) {
  return (
    <AuthContext.Provider value={props}>
    {!!props.status === 'authenticated' ? <Profile /> : <Login />}
    </AuthContext.Provider>
  );
}
 
function Profile() {
  const { status, user } = useAuthContext('authenticated');
 
  return <div>Welcome, {user.name}!</div>;
}

It would error if we tried something like:

function Profile() {
  const { status, user } = useAuthContext('error'); // ❌ can't find user
  const { status, user } = useAuthContext('idle'); // ❌ can't find user
  const { status, user } = useAuthContext('authenticated'); // ✅ works
  const { status, user } = useAuthContext(); // ❌ can't find user
 
  return <div>Welcome, {user.name}!</div>;
}

This is working in the way that I would like. Where I can guard against which context will be used. Which could be used on the other components as well.

function Login() {
  const auth = useAuthContext('idle');
 
  return <div>Login here, {auth.status /* idle */}!</div>;
}

Improving the overloading function

Personally, I'd like function that could be given a discriminated union with the discriminate key value. So all of those overloaded functions would be created for me.

I'm also going to remove the ability for an empty () to be replaced with ("default") in the future. To force choosing hook you plan to use.

Discriminate function allowing ()

This first example allows for an empty ().

function createDiscriminatedContext<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
>(discriminantKey: TDiscriminant, defaultValue: TUnion) {
  const Ctx = createContext<TUnion>(defaultValue);
 
  // Overloads for the single hook
  function useDiscriminatedContext(): TUnion;
  function useDiscriminatedContext<
    TValue extends TUnion[TDiscriminant],
  >(
    expected: TValue,
  ): Extract<TUnion, { [K in TDiscriminant]: TValue }>;
 
  function useDiscriminatedContext(expected?: TUnion[TDiscriminant]) {
    const value = useContext(Ctx);
 
    if (expected !== undefined && value[discriminantKey] !== expected) {
      throw new Error(
        `Expected ${discriminantKey}=${String(expected)}, got ${String(
          value[discriminantKey],
        )}`,
      );
    }
    return value;
  }
  return {
    Context: Ctx,
    useContext: useDiscriminatedContext,
  };
}

This would work just like before

function Profile() {
  const { status, user } = useAuthContext('error'); // ❌ can't find user
  const { status, user } = useAuthContext('idle'); // ❌ can't find user
  const { status, user } = useAuthContext('authenticated'); // ✅ works
  const { status, user } = useAuthContext(); // ❌ can't find user
 
  return <div>Welcome, {user.name}!</div>;
}

Discriminate function not allowing ()

function createDiscriminatedContext<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
>(discriminantKey: TDiscriminant, defaultValue: TUnion) {
  const Ctx = createContext<TUnion>(defaultValue);
 
  // Required discriminant argument
  function useDiscriminatedContext<
    TValue extends TUnion[TDiscriminant],
  >(
    expected: TValue,
  ): Extract<TUnion, { [K in TDiscriminant]: TValue }>;
 
  function useDiscriminatedContext(expected: TUnion[TDiscriminant]) {
    const value = useContext(Ctx);
 
    if (value[discriminantKey] !== expected) {
      throw new Error(
        `Expected ${discriminantKey}=${String(expected)}, got ${String(
          value[discriminantKey],
        )}`,
      );
    }
 
    return value;
  }
 
  return {
    Context: Ctx,
    useContext: useDiscriminatedContext,
  };
}
// All of these now:
useAuthContext(); // ❌ Type error
useAuthContext("default"); // ✅ works
useAuthContext("idle"); // ✅ idle
useAuthContext("authenticated"); // ✅ authenticated

Discriminate function not allowing () and requiring ("default")

Like I mentioned earlier. I want to removed the ability to use a blank () to require ("default") if it's something the user wants to use.

We will also give some guidance if the user is using default as their value. It will tell the user Use useContext("${TValue}") to access "${TKey & string}" (requires ${TDiscriminant}="${TValue}"). Which will give a bit of guidance but not much.

Throws an error if the actual context value doesn't match the expected discriminant, catching bugs early

import { createContext, useContext } from "react";
 
type AllKeysOfUnion<T> = T extends unknown ? keyof T : never;
 
type DiscriminantForKey<
  TUnion,
  TDiscriminant extends keyof TUnion,
  TKey extends PropertyKey,
> = TUnion extends unknown
  ? TKey extends keyof TUnion
    ? TUnion[TDiscriminant]
    : never
  : never;
 
type NarrowingHint<
  TDiscriminant extends string,
  TKey extends PropertyKey,
  TValue,
> = TValue extends string | number
  ? `Use useContext("${TValue}") to access "${TKey & string}" (requires ${TDiscriminant}="${TValue}")`
  : never;
 
type DefaultReturnType<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
> = TUnion & {
  readonly [K in Exclude<
    AllKeysOfUnion<TUnion>,
    keyof TUnion
  >]?: NarrowingHint<
    TDiscriminant,
    K,
    DiscriminantForKey<TUnion, TDiscriminant, K>
  >;
};
 
export type DiscriminantValues<
  TUnion,
  TKey extends keyof TUnion,
> = TUnion extends unknown ? TUnion[TKey] : never;
 
export function createDiscriminatedContext<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
>(discriminantKey: TDiscriminant) {
  const Ctx = createContext<TUnion | null>(null);
 
  type ValidValues = DiscriminantValues<TUnion, TDiscriminant>;
 
  const DEFAULT_VALUE = "default" as const;
  type DefaultValue = typeof DEFAULT_VALUE;
 
  function useDiscriminatedContext(
    expected: DefaultValue,
  ): DefaultReturnType<TUnion, TDiscriminant>;
  function useDiscriminatedContext<TValue extends ValidValues>(
    expected: TValue,
  ): Extract<TUnion, { [K in TDiscriminant]: TValue }>;
  function useDiscriminatedContext(
    expected: ValidValues | DefaultValue,
  ) {
    const contextValue = useContext(Ctx);
 
    if (contextValue === null) {
      throw new Error(
        "useContext must be used within a Provider. Wrap your component tree with <Context.Provider>.",
      );
    }
 
    const value: TUnion = contextValue;
 
    if (
      expected !== DEFAULT_VALUE &&
      value[discriminantKey] !== expected
    ) {
      throw new Error(
        `Expected ${discriminantKey}=${String(expected)}, got ${String(value[discriminantKey])}`,
      );
    }
 
    return value;
  }
 
  return {
    Context: Ctx,
    useContext: useDiscriminatedContext,
  } as const;
}
function Profile() {
  const { status, user } = useAuthContext('error'); // ❌ can't find user
  const { status, user } = useAuthContext('idle'); // ❌ can't find user
  const { status, user } = useAuthContext('authenticated'); // ✅ works
  const { status, user } = useAuthContext(); // ❌ missing input value
  const { status, user } = useAuthContext("default"); // ✅ user is possibly undefined
 
  return <div>Welcome, {user.name}!</div>;
}

Summary of solution

What did we create here?

We created createDiscriminatedContext — a generic factory function that generates type-safe React contexts for discriminated union types. It solves the problem of TypeScript not knowing which variant of a union is being used inside child components, even when the parent's conditional rendering guarantees a specific variant.

Key features:

  • Automatic type narrowing: Pass the discriminant value (e.g., "authenticated") to the hook, and TypeScript returns the exact narrowed type instead of the full union
  • Function overloading: Uses TypeScript's function overloading to map input discriminant values to specific return types
  • Runtime validation: Throws an error if the actual context value doesn't match the expected discriminant, catching bugs early
  • Developer guidance: Provides helpful type hints suggesting which discriminant value to use when accessing properties that require narrowing
  • Enforced usage: Requires a discriminant argument (no empty () calls), forcing developers to be explicit about which variant they expect

Instead of manually destructuring and checking status in every component, you can now write useAuthContext("authenticated") and get back { status: "authenticated"; user: { name: string; email: string } } directly — with full type safety and runtime protection.

Discriminated generic overloading function w/ comments (Code)

import { createContext, useContext } from "react";
 
/**
 * Gets all keys from all members of a union type.
 */
type AllKeysOfUnion<T> = T extends unknown ? keyof T : never;
 
/**
 * Gets the discriminant value(s) for union members that contain a specific key.
 */
type DiscriminantForKey<
  TUnion,
  TDiscriminant extends keyof TUnion,
  TKey extends PropertyKey,
> = TUnion extends unknown
  ? TKey extends keyof TUnion
    ? TUnion[TDiscriminant]
    : never
  : never;
 
/**
 * A hint type that suggests which discriminant value to use for a property.
 */
type NarrowingHint<
  TDiscriminant extends string,
  TKey extends PropertyKey,
  TValue,
> = TValue extends string | number
  ? `Use useContext("${TValue}") to access "${TKey & string}" (requires ${TDiscriminant}="${TValue}")`
  : never;
 
/**
 * Creates a type for the 'default' return value that provides helpful hints
 * for properties that require narrowing.
 */
type DefaultReturnType<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
> = TUnion & {
  readonly [K in Exclude<
    AllKeysOfUnion<TUnion>,
    keyof TUnion
  >]?: NarrowingHint<
    TDiscriminant,
    K,
    DiscriminantForKey<TUnion, TDiscriminant, K>
  >;
};
 
/**
 * Extracts all possible values of a discriminant key from a union type.
 *
 * @example
 * type AuthState =
 *   | { status: 'idle' }
 *   | { status: 'loading' }
 *   | { status: 'authenticated'; user: User };
 *
 * type StatusValues = DiscriminantValues<AuthState, 'status'>;
 * // Result: 'idle' | 'loading' | 'authenticated'
 */
export type DiscriminantValues<
  TUnion,
  TKey extends keyof TUnion,
> = TUnion extends unknown ? TUnion[TKey] : never;
 
/**
 * Creates a type-safe React context for discriminated union types.
 *
 * This function creates a context and a custom hook that supports automatic
 * type narrowing based on the discriminant value.
 *
 * @param discriminantKey - The key used as the discriminant in the union type
 * @returns An object containing the Context and a useContext hook
 * @throws Error if useContext is called outside of a Provider
 *
 * @example
 * type AuthState =
 *   | { status: 'idle' }
 *   | { status: 'loading' }
 *   | { status: 'authenticated'; user: { name: string } }
 *   | { status: 'error'; error: string };
 *
 * const { Context, useContext } = createDiscriminatedContext<AuthState, 'status'>(
 *   'status'
 * );
 *
 * // In a component:
 * const auth = useContext('authenticated');
 * // auth is typed as: { status: 'authenticated'; user: { name: string } }
 */
export function createDiscriminatedContext<
  TUnion,
  TDiscriminant extends keyof TUnion & string,
>(discriminantKey: TDiscriminant) {
  const Ctx = createContext<TUnion | null>(null);
 
  // Use the helper type for clearer parameter typing
  type ValidValues = DiscriminantValues<TUnion, TDiscriminant>;
 
  // Special value to get the full union without narrowing
  const DEFAULT_VALUE = "default" as const;
  type DefaultValue = typeof DEFAULT_VALUE;
 
  /**
   * Hook to consume the discriminated context with type narrowing.
   *
   * @param expected - The discriminant value to narrow the type. Must be one of the valid
   *                   discriminant values from the union type (e.g., 'idle' | 'loading' | 'error'),
   *                   or 'default' to get the full union type without narrowing.
   * @returns The context value, narrowed to the specific union member matching the expected value,
   *          or the full union type if 'default' is passed.
   * @throws Error if expected value (other than 'default') doesn't match the actual discriminant
   *
   * @example
   * // For a union with status: 'idle' | 'loading' | 'authenticated' | 'error'
   * const auth = useContext('authenticated');
   * // auth is narrowed to: { status: 'authenticated'; user: { name: string } }
   *
   * // To get the full union type without narrowing:
   * const auth = useContext('default');
   * // auth is the full union: AuthState
   */
  function useDiscriminatedContext(
    expected: DefaultValue,
  ): DefaultReturnType<TUnion, TDiscriminant>;
  function useDiscriminatedContext<TValue extends ValidValues>(
    expected: TValue,
  ): Extract<TUnion, { [K in TDiscriminant]: TValue }>;
  function useDiscriminatedContext(
    expected: ValidValues | DefaultValue,
  ) {
    const contextValue = useContext(Ctx);
 
    if (contextValue === null) {
      throw new Error(
        "useContext must be used within a Provider. Wrap your component tree with <Context.Provider>.",
      );
    }
 
    const value: TUnion = contextValue;
 
    if (
      expected !== DEFAULT_VALUE &&
      value[discriminantKey] !== expected
    ) {
      throw new Error(
        `Expected ${discriminantKey}=${String(expected)}, got ${String(value[discriminantKey])}`,
      );
    }
 
    return value;
  }
 
  return {
    /**
     * The React Context object. Use with Context.Provider to provide values.
     */
    Context: Ctx,
    /**
     * Hook to consume the discriminated context with required type narrowing.
     * You must specify a discriminant value to narrow the type.
     */
    useContext: useDiscriminatedContext,
  } as const;
}

Application example (code)

type AuthStateProps =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "authenticated"; user: { name: string; email: string } }
  | { status: "error"; error: string };
 
const AuthContextValue = AuthStateProps;
 
const AuthContext = createContext<AuthContextValue>(
  {} as AuthContextValue,
);
// Create the context with the discriminant key
const { Context: AuthContext, useContext: useAuthContext } =
  createDiscriminatedContext<AuthState, "status">("status");
// Use in a provider
const Parent(props: AuthStateProps) {
 
  return (
    <AuthContext.Provider value={auth}>
    {!!props.status === 'authenticated' ? <Profile /> : <Login />}
    </AuthContext.Provider>
  );
}
 
function Profile() {
  const { status, user } = useAuthContext('authenticated'); // ✅
 
  return <div>Welcome, {user.name}!</div>;
}

Final Statement

Handing the typing on react context in this way. Likely has unforeseen risks that I have not fully comprehended at this moment. A lot of technology is learned though failing and hitting roadblocks. Currently this is not used in production at this post time.

If you like this code you can find it on npm: @bender-tools/react-discriminated-union-context

Thanks for sticking around and reading my thoughts. Have fun 😘