I have the following Document structure:
onst blockTypes = [
'Title',
'Image',
] as const;
type BlockType = typeof blockTypes[number];
interface IDocumentBlock {
id: string;
type: BlockType;
position: number;
}
interface IConfigurableDocumentBlock<T> extends IDocumentBlock {
config: T;
}
interface ITitleBlockConfig {
size: number;
subtitle: boolean;
}
interface ITitleBlock
extends IConfigurableDocumentBlock<ITitleBlockConfig> {
type: 'Title';
content: string;
}
interface IImageBlockConfig {
alignment: 'center' | 'left' | 'right';
}
interface IImageBlock
extends IConfigurableDocumentBlock<IImageBlockConfig> {
type: 'Image';
source: string;
title?: string;
}
type DocumentBlock =
| IImageBlock
| ITitleBlock
const isConfigurableBlock = (
block: IDocumentBlock
): block is IConfigurableDocumentBlock<unknown> => {
return (block as IConfigurableDocumentBlock<unknown>).config !== undefined;
};
For blocks which are configurable there is an BlockConfigurator type which can be passed to anywhere to configure those blocks:
type BlockConfigurator<
T extends IConfigurableDocumentBlock<unknown>,
V extends keyof T['config']
> = T extends IConfigurableDocumentBlock<infer R>
? {
blockType: T['type'];
title: string;
parameter: V;
value: T['config'][V] | ((config: R) => T['config'][V]);
}
: never;
const ImageBlockConfigurator: BlockConfigurator<IImageBlock, 'alignment'> =
{
blockType: 'Image',
title: 'Right',
parameter: 'alignment',
value: (config) => (config.alignment === 'center' ? 'left' : 'right'),
};
const TitleBlockConfigurator: BlockConfigurator<ITitleBlock, 'size'> = {
blockType: 'Title',
title: 'Font Size 2',
parameter: 'size',
value: 2,
};
type Configurator =
| typeof ImageBlockConfigurator
| typeof TitleBlockConfigurator;
I'm having a bit of trouble connecting those two types. I know any Object I get which is an BlockConfigurator HAS to have valid parameter and value types so I should never get an error if I just check the block type. But the cast to any looks really ugly and I was wondering if there is a way to write a smarter typeguard?
const configureBlock = (block: DocumentBlock, configurator: Configurator) => {
if (
isConfigurableBlock(block) &&
block.type === configurator.blockType
) {
const value =
typeof configurator.value === 'function'
? configurator.value(block.config as any)
: configurator.value;
(block.config as any)[configurator.parameter] = value;
}
}
Here is a link to a playground file if it helps.
E: For example this would eliminate the any cast but I would have to manually write any parameter value and blocktype combination:
if (
isConfigurableBlockNormalized(block) &&
block.type === configurator.blockType
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (
configurator.blockType === 'Title' &&
configurator.parameter === 'size' &&
block.type === 'Title'
) {
const value =
typeof configurator.value === 'function'
? configurator.value(block.config)
: configurator.value;
block.config[configurator.parameter] = value;
}
}
The contents of the if block would be exactly the same for any combination, just the condition would change. But those conditions are baked in to the Configurator itself, and have to be true.
This is tough. I can understand the problem, which is that block.type === configurator.blockType
does not guard the types in way where TypeScript knows that configurator.value
and block.config
must be a matching pair.
Your current isConfigurableBlock
check is only useful in this configureBlock
function as a runtime safeguard. It is already known that all members of the DocumentBlock
union are configurable as both IImageBlock
and ITitleBlock
extend IConfigurableDocumentBlock
. So isConfigurableBlock(block)
must always be true.
What we need to be checking is that the block
variable is configurable by this specific configurator
variable.
My first approach was to use a higher-order function to create a type guard that is specific to the configurator. This is a generic mess, asserts things that are beyond the scope of what's actually checked, and still doesn't work. For reference:
// do not try this at home -- bad code below
const isConfigurableBlock = <Config, Key extends keyof Config, Block extends IConfigurableDocumentBlock<Config>>(
configurator: BlockConfigurator<Block, Key>
) => (block: IConfigurableDocumentBlock<unknown>): block is IConfigurableDocumentBlock<Config> => {
return block.type === configurator.blockType;
}
Then I took a more "back to the drawing board" look at these types. The current check just looks at the blockType
and relies on the assumption that members of the DocumentBlock
union with a particular block type have the same config
type as members of the Configurator
union with that block type. This is currently true, but not inherently or automatically true.
We want to make that relationship more explicit. We can do that by using DocumentBlock
as the canonical reference point for the relationship.
type ConfigForType<T extends BlockType> = Extract<DocumentBlock, {type: T}>['config']
type Check = ConfigForType<'Image'> // inferred as IImageBlockConfig -- correct
I would drop one level of extends
and have the blocks extend IDocumentBlock
directly rather than going through IConfigurableDocumentBlock
. Note that ITitleBlock
is still assignable to IConfigurableDocumentBlock<ITitleBlockConfig>
since it meets the conditions of that type, regardless of whether it extends it.
interface ITitleBlock extends IDocumentBlock {
type: 'Title';
config: ITitleBlockConfig
content: string;
}
The BlockConfigurator
generic depends on the entire block object ITitleBlock
but the only properties that it actually looks at are the type
and config
. It can configure a block regardless of whether that block has the content
property which is required of an ITitleBlock
.
We can actually remove the conditional from the BlockConfigurator
type. It just needs to know the BlockType
and the property name. It can get the config from the previously-established canonical relationship.
type BlockConfigurator<
T extends BlockType,
V extends keyof ConfigForType<T>
> = {
blockType: T;
title: string;
parameter: V;
value: ConfigForType<T>[V] | ((config: ConfigForType<T>) => ConfigForType<T>[V]);
}
This also makes it much easier to infer the generics of the BlockConfigurator
since they are both strings which are directly present in the object. There is no longer any unknown outside information which needs to be specified.
You can use direct assignment like you had before:
const TitleBlockConfiguratorT2: BlockConfigurator<'Title', 'size'> = { ...
But you can also do this:
// identity function which validates types
const createBlockConfigurator = <
T extends BlockType,
V extends keyof ConfigForType<T>
>(config: BlockConfigurator<T, V>) => config;
// type is inferred as BlockConfigurator<"Title", "size">
const TitleBlockConfiguratorT2 = createBlockConfigurator({
blockType: 'Title',
title: 'Font Size 2',
parameter: 'size',
// type of config is inferred as ITitleBlockConfig
value: (config) => config.size,
});
This configureBlock
function is a real pain though. Not even my go-to union of valid pairings trick works here. If I hadn't already invested enough time and energy I would have given up at this point. But I have, so I've to find something that works even if it's not perfect.
The problem with applying the higher-order function approach here is that the generic of the first function won't be specific since our configurator
argument is of type Configurator
. In order to get that specificity, we can attach the type guard to the individual configurator objects. We can use the createBlockConfigurator
function demonstrated earlier, but modify it to include a property which is a type guard.
const createBlockConfigurator = <
T extends BlockType,
V extends keyof ConfigForType<T>
>(config: BlockConfigurator<T, V>) => ({
...config,
canConfigure: <B extends DocumentBlock>(block: B): block is B & { type: T; config: ConfigForType<T>; } =>
block.type === config.blockType
});
This still doesn't work. So now I'm mad as hell and I need to defeat the red squiggles.
The next step is to call the function through a property of the configurator object.
I am confused by what you are doing here block.config[configurator.parameter] = value;
. With the value creator function on the configurator, you are using block.config
as the basis for the value
. But you are also using the value
as the basis for setting block.config
.
Pick one or the other. Perhaps the block
that we are processing doesn't have the config
property yet and we are creating it -- but then this whole question is moot.
So for now I'm just assuming that you want to potentially call a function that takes the config from the block and creates a value. I'm ignoring the illogical thing that you are doing with that computed value.
When provided with an invalid block
argument, the configurator can either throw an Error
or return some value like null
or undefined
that you later check for.
const createBlockConfigurator = <
T extends BlockType,
V extends keyof ConfigForType<T>
>(config: BlockConfigurator<T, V>) => {
const canConfigure = <B extends DocumentBlock>(block: B): block is B & { type: T; config: ConfigForType<T>; } =>
block.type === config.blockType;
const computeValue = (block: DocumentBlock): ConfigForType<T>[V] => {
if ( ! canConfigure(block) ) {
throw new Error(`Invalid block object. Expected type ${config.blockType} but received type ${block.type}`);
}
// error on the next line
return ( typeof config.value === "function" ) ? config.value(block.config) : config.value;
}
return {
...config,
canConfigure,
computeValue,
}
};
const configureBlock = (block: DocumentBlock, configurator: Configurator) => {
// no problems here
const value = configurator.computeValue(block);
}
Now we're almost there except I keep getting infuriating errors, like this one:
Not all constituents of type '((config: ConfigForType) => ConfigForType[V]) | (ConfigForType[V] & Function)' are callable.
Type 'ConfigForType[V] & Function' has no call signatures.
I believe that this error is due to the possibility that the value property which we are computing is itself a function. We know that never happens, but I believe that ConfigForType<T>[V]
is not evaluated fully to a union of all possible constituents since it is a generic.
So...I guess we can make that assertion with another type guard? But it's starting to become a real mess and also starting to look like a job for a class
. And I can't even get that guard to work so now I officially give up.