Okay, I think I've got it, but it's ugly and I bet it can be improved. I've used a mapped type called DataOrFuncValuesObject
that maps the target type (Schema
in the example) to an object type where the keys are the same but the value types are a union of objects, one in the original form (col1: string
) and one in the function form (
"$col1.$func": Record<string, any[]>
). Then I use a type inspired by this answer which I've called
PropertyValueIntersection
to extract those property values as an intersection. So given:
type Schema = {
col1: string;
col2: string;
};
...the result is:
type Result =
& ({ col1: string} | {$"col1.$func": Record<string, any[]>})
& ({ col2: string} | {$"col2.$func": Record<string, any[]>});
Here it is:
export const FUNC_ENDING_HINT = "$func" as const;
// Converts Symbol to never (see next type)
type NonSymbol<K extends PropertyKey> = K extends Symbol ? never : K;
// Converts the given object into an object where each property's type is
// a union of the key and its value type or the mapped key and the
// `Record<string, any[]>` type.
// Note: You only need the `NonSymbol` thing below if your configuration
// has `keyofStringsOnly` turned off.
type DataOrFuncValuesObject<ObjectType> = {
[Key in keyof ObjectType]:
| { [K in Key]: ObjectType[Key] }
| { [K in `${NonSymbol<Key>}.${typeof FUNC_ENDING_HINT}`]: Record<string, any[]> };
};
// Extract the property types of the properties in the given object
// as an intersection; see https://stackoverflow.com/a/66445507/157247
// (It's called `ParamIntersection` there and isn't generic, but that's
// where I got this from.)
type PropertyValueIntersection<O> = {
[K in keyof O]: (x: O[K]) => void;
}[keyof O] extends (x: infer I) => void
? I
: never;
// Our mapped type
type DataOrFuncFromObject<TD> = PropertyValueIntersection<DataOrFuncValuesObject<TD>>;
The NonSymbol
thing in the template literal in DataOrFuncValuesObject
feels like a hack, it seems to me I should be able exclude symbol
from the possible types of Key
earlier, but my attempts to do that failed.
Usage/tests:
type Schema = {
col1: string;
col2: string;
};
// OK
const basic: DataOrFuncFromObject<Schema> = {
col1: "",
col2: "",
};
// OK
const funcs: DataOrFuncFromObject<Schema> = {
"col1.$func": { func: [] },
"col2.$func": { func: [] },
};
// OK
const mixed: DataOrFuncFromObject<Schema> = {
col1: "",
"col2.$func": { func: [] },
};
// ERROR, missing `col1` / `"col1.$func"
const wrong: DataOrFuncFromObject<Schema> = {
"col2.$func": { func: [] },
};
Playground link
That version does allow both col1
and "col1.$func"
in the same object, which you confirmed in a comment was okay (though not ideal). But if you want to disallow that, we can do that by modifying DataOrFuncValuesObject
to include the "other" property in each object type in the union as an optional property with the type never
:
// Converts the given object into an object where each property's type is
// a union of the key and its value type or the mapped key and the
// `Record<string, any[]>` type.
// Note: You only need the `NonSymbol` thing below if your configuration
// has `keyofStringsOnly` turned off.
type DataOrFuncValuesObject<ObjectType> = {
[Key in keyof ObjectType]:
| ({ [K in Key]: ObjectType[Key] } & {
[K in `${NonSymbol<Key>}.${typeof FUNC_ENDING_HINT}`]?: never;
})
| ({ [K in `${NonSymbol<Key>}.${typeof FUNC_ENDING_HINT}`]: Record<string, any[]> } & {
[K in Key]?: never;
});
};
Then this test happily fails:
// ERROR, can't have both `col1` and `"col1.$func"`
const wrong2: DataOrFuncFromObject<Schema> = {
col1: "",
"col1.$func": { func: [] },
"col2.$func": { func: [] },
};
Playground link