Why are variable typing of children not passed with context?


Why doesn't variable typing of children pass with context?
Table of Contents
-
Understanding the problem (basic discriminated union setup with context)
-
Typing out the discriminated props for context
- What is an overloaded hook (function overloading in TypeScript)?
- Why can we have the same function name multiple times?
- Inline overloading
- Improving the overloading function
- Discriminate function allowing ()
- Discriminate function not allowing ()
- Discriminate function not allowing () and requiring ("default")
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:
-
Overload signatures (the repeated
function useAuthContext(...)lines) tell TypeScript: "When someone calls this function with these specific arguments, return this specific type." -
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"); // ✅ authenticatedDiscriminate 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 😘