
Key vs Value Optional
A critical TypeScript pattern that LLMs (and developers) often get wrong
If you're working with TypeScript and large language models (LLMs), there's a crucial distinction you need to understand: the difference between key optional andvalue optional properties. This seemingly small detail can make or break your application's reliability, especially when dealing with telemetry, logging, or any data that needs to flow through multiple function calls.
Why this matters: LLMs love adding question marks to make properties optional, but this can accidentally break data flow in your application when values need to be passed down through function chains.
The Problem: When Optional Goes Wrong
Let's start with a real-world example. Imagine you have a module that handles telemetry tracing - think OpenTelemetry where you need to pass trace IDs through your application to track what happens across different functions.
❌ The problematic approach
// This looks innocent, but it's a trap!
type Context = {
traceId?: string; // Key optional - can be omitted entirely
};
function main(ctx: Context) {
// Uh oh - we're not passing traceId down!
doThing({}); // No telemetry data flows through
doAnotherThing({}); // Still no telemetry data
}
function doThing(ctx: Context) {
// This function might fail, but we won't know why
// because traceId was omitted and we have no telemetry
}
function doAnotherThing(ctx: Context) {
// Same problem here
}
The issue here is subtle but critical. In production, when main
is called with a traceId
, that valuable telemetry data gets lost because the internal functions don't receive it. If doThing
or doAnotherThing
fail, you'll have no trace of what went wrong.
Understanding the Two Approaches
type Context = {
traceId?: string;
}
// Can be called like:
someFunction({})
someFunction({ traceId: "abc123" })
The property can be completely omitted from the object. Great for public APIs where you want clean interfaces.
type Context = {
traceId: string | undefined;
}
// Must be called like:
someFunction({ traceId: undefined })
someFunction({ traceId: "abc123" })
The property must be provided but can be undefined. Ensures values are explicitly passed through function chains.
The Solution: Value Optional for Data Flow
Here's how to fix our telemetry example using value optional types:
✅ The correct approach
type Context = {
traceId: string | undefined; // Value optional - must be provided
};
function main(ctx: Context) {
// Now we're forced to pass the traceId down
doThing({ traceId: ctx.traceId });
doAnotherThing({ traceId: ctx.traceId });
}
function doThing(ctx: Context) {
// We can be confident that traceId was explicitly passed
// Even if it's undefined, it was intentionally passed as undefined
if (ctx.traceId) {
// Add telemetry with the trace ID
console.log(`Starting doThing with trace: ${ctx.traceId}`);
}
}
function doAnotherThing(ctx: Context) {
// Same confidence here
if (ctx.traceId) {
console.log(`Starting doAnotherThing with trace: ${ctx.traceId}`);
}
}
With this approach, TypeScript forces us to explicitly pass the traceId
through each function call. Even if it's undefined
, we know it was intentionally passed as undefined
, not accidentally omitted.
When to Use Each Approach
- • Public APIs that should look clean
- • Configuration objects
- • Optional features that don't flow through function chains
- • When backwards compatibility matters
- • Data that flows through function chains
- • Internal application code
- • When you need to guarantee a value is considered
- • Telemetry, logging, and debugging data
The Hybrid Approach: Best of Both Worlds
In practice, you often want a clean public API (key optional) but reliable internal data flow (value optional). Here's how to achieve both:
🎯 Hybrid approach example
// Public API - clean and optional
type PublicContext = {
traceId?: string;
};
// Internal API - explicit and required
type InternalContext = {
traceId: string | undefined;
};
// Public entry point - key optional for clean API
export function main(ctx: PublicContext) {
const internalCtx: InternalContext = {
traceId: ctx.traceId ?? undefined
};
// Now internal functions get explicit values
doThing(internalCtx);
doAnotherThing(internalCtx);
}
// Internal functions - value optional for reliability
function doThing(ctx: InternalContext) {
// We know traceId was explicitly considered
}
function doAnotherThing(ctx: InternalContext) {
// Same confidence here
}
This gives you a clean public interface while ensuring that internal data flow is explicit and reliable. Your users can call main()
cleanly, but internally you have guarantees about data flow.
Practical Tips for Teams
When working with AI assistants, be explicit about your intent. Instead of saying “make this optional,” specify whether you want “key optional” (can omit) or “value optional” (must provide, can be undefined).
When reviewing TypeScript code, pay special attention to optional properties in function parameters. Ask: “Does this value need to flow through to other functions?”
When refactoring, identify data that needs to flow through function chains (like user IDs, trace IDs, request contexts) and convert them to value optional.
Key Takeaways
- 1Key optional (
property?: Type
) means the property can be omitted entirely - great for clean APIs - 2Value optional (
property: Type | undefined
) means the property must be provided but can be undefined - essential for data flow - 3Use value optional for any data that needs to flow through function chains, especially telemetry and context data
- 4Consider a hybrid approach: key optional for public APIs, value optional for internal functions
- 5Be explicit with LLMs about which type of optional you want
Understanding this distinction will make your TypeScript code more reliable and help you avoid subtle bugs where important data gets lost in function calls. It's a small detail that makes a big difference in application reliability.