How can I add unknown string properties to a TypeScript type without TypeScript complaining?
Consider this basic TypeScript type:
type A = {
id: number;
name?: string;
age?: number;
email: string;
created: Date;
}
I want to extend this type to allow additional unknown properties with string values. My first attempt was:
type ExtendedA = A & Record<Exclude<string, keyof A>, string>;
However, TypeScript complains that defined properties are not assignable to string, even though I excluded them from the record.
I also tried:
type ExtendedA = A & {
[K in string]: K extends keyof A ? never : string;
}
But TypeScript seems to stop at “never” and doesn’t properly check if the key exists in type A.
The use case is for a text parser that processes known properties and adds unknown ones with raw string values. Is TypeScript capable of handling this scenario?
To extend a TypeScript type with unknown string properties while maintaining type safety for known properties, you need to use a different approach than simple intersection types. The issue with your attempts is that TypeScript performs an intersection check across all properties, which causes conflicts between the original type and the record type.
The correct solution is to use a mapped type with conditional types to properly exclude known properties:
type ExtendedA = A & {
[K in Exclude<string, keyof A>]: string;
}
However, this still has issues because TypeScript’s string type is too broad. A more robust solution is:
type ExtendedA = A & {
[K in Exclude<string, keyof A>]: string;
}
Contents
- Basic Solution for Unknown String Properties
- Understanding the Problem
- Working with Dynamic Properties
- Best Practices for Text Parsing
- Alternative Approaches
- Complete Example Implementation
Basic Solution for Unknown String Properties
The most effective way to allow unknown string properties while maintaining type safety is to use a mapped type with proper exclusion:
type ExtendedA = A & Record<string, string>;
Wait, this still has the same issue. Let me provide the correct solution:
type ExtendedA = A & {
[key: string]: string;
}
No, this still conflicts. The proper solution is more nuanced:
type ExtendedA = A & {
[K in keyof A]?: any;
} & {
[K in Exclude<string, keyof A>]: string;
}
Actually, let me look at the search results more carefully. From the StackOverflow result, I can see:
type InputType = Omit<Record<string, unknown>, keyof AnotherType>
This suggests the correct approach is:
type ExtendedA = A & Omit<Record<string, string>, keyof A>;
This should work because it creates a record type with string values, then excludes all the known properties from A, and finally intersects with the original type A.
Understanding the Problem
The issue you’re encountering stems from TypeScript’s strict type checking when working with intersection types. When you write:
type ExtendedA = A & Record<Exclude<string, keyof A>, string>;
TypeScript performs a full intersection analysis across all properties. Even though you’re trying to exclude known properties with Exclude<string, keyof A>, the compiler still checks property compatibility between A and the Record type, which causes conflicts like id: number vs. string values.
From the TypeScript documentation, we understand that intersection types require all properties to be compatible, which is why your approach fails.
Working with Dynamic Properties
For your text parser use case, here’s a more practical approach:
type ExtendedA = A & {
[key: string]: string | undefined;
}
This approach allows any string property while maintaining compatibility with the original type. However, it’s not ideal because it allows undefined values for unknown properties.
A better solution is:
type ExtendedA = A & {
[key: string]: string;
}
But this will still cause conflicts with the original type’s properties. The most robust solution is:
type ExtendedA = A & {
[key: string]: string;
} & {
[key in keyof A]?: any;
}
This creates a type that:
- Maintains all properties from
A - Allows any string properties
- Makes all original properties optional to avoid conflicts
Best Practices for Text Parsing
For your text parser use case, consider these best practices:
type ExtendedA = A & {
[key: string]: string | undefined;
}
// Usage in parser
function parseText(input: string): ExtendedA {
const result: Partial<A> = {};
const unknownProps: Record<string, string> = {};
// Parse known properties
// ...
// Parse unknown properties
// ...
return { ...result, ...unknownProps } as ExtendedA;
}
This approach separates known and unknown properties, then combines them at runtime while maintaining type safety.
Alternative Approaches
1. Using Index Signatures with Conditional Types
type ExtendedA = A & {
[K in keyof A]?: any;
} & {
[K in Exclude<string, keyof A>]: string;
}
This approach uses mapped types to properly handle the exclusion of known properties.
2. Using Utility Types
From the search results, we can see that using Omit with Record is a common pattern:
type ExtendedA = A & Omit<Record<string, string>, keyof A>;
3. Using Partial for Known Properties
type ExtendedA = Partial<A> & {
[key: string]: string;
} & {
[key in keyof A]?: never;
}
This makes known properties optional and prevents conflicts.
Complete Example Implementation
Here’s a complete implementation for your text parser use case:
type A = {
id: number;
name?: string;
age?: number;
email: string;
created: Date;
}
type ExtendedA = A & {
[key: string]: string | undefined;
}
function parseText(input: string): ExtendedA {
const result: Partial<A> = {};
const unknownProps: Record<string, string> = {};
// Parse known properties
// Example logic:
if (input.includes('id:')) {
const idMatch = input.match(/id:(\d+)/);
if (idMatch) {
result.id = parseInt(idMatch[1]);
}
}
// Parse unknown properties
const unknownMatches = input.match(/(\w+):([^\s]+)/g);
if (unknownMatches) {
unknownMatches.forEach(match => {
const [key, value] = match.split(':');
if (!(key in result)) { // Check if it's not a known property
unknownProps[key] = value;
}
});
}
return { ...result, ...unknownProps } as ExtendedA;
}
// Usage
const parsed = parseText("id:123 name:John customProp:value email:john@example.com");
console.log(parsed.id); // 123
console.log(parsed.customProp); // "value"
This implementation provides a practical solution for your text parsing use case while maintaining type safety and allowing unknown string properties.
Sources
- TypeScript Documentation - Utility Types
- StackOverflow - Exclude properties from type or interface in TypeScript for an arbitrary object
- Reddit - How do i make typescript not ignore properties that i haven’t defined in a type ?
- Lloyd Atkinson - Typing Unknown Objects in TypeScript With Record Types
Conclusion
TypeScript is capable of handling your scenario, but requires careful type construction. The key insights are:
-
Intersection types require property compatibility - Simple intersections will fail due to type conflicts between known and unknown properties.
-
Use mapped types for dynamic property handling - Mapped types with conditional logic provide the flexibility needed for unknown properties.
-
Consider runtime separation - For text parsing, separating known and unknown properties at runtime then combining them often works better than complex type constructions.
-
Type assertions can bridge the gap - When TypeScript’s strict checking is too restrictive, type assertions (
as) can help bridge the gap between runtime behavior and type safety.
The most practical solution for your use case is to use Partial<A> for known properties and a simple index signature for unknown properties, then combine them at runtime with a type assertion.