You have two options to handle it.
First one
/**
* Please refer to this link for explanation
* https://stackoverflow.com/a/50375286
*/
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I
) => void
? I
: never;
type PropertyType = 'string' | 'number';
interface RequiredProperty<T> {
default: T;
optional?: false;
}
interface OptionalProperty<T> {
default?: T;
optional: true;
}
type BaseProperty<Kind extends PropertyType, Type> = (
| RequiredProperty<Type>
| OptionalProperty<Type>
) & {
kind: Kind;
};
type StringType = BaseProperty<'string', string>;
type NumberType = BaseProperty<'number', number>;
type ModelProperty = StringType | NumberType;
interface Model {
[x: string]: ModelProperty;
}
type Values<T> = T[keyof T]
/**
* Translates string type name to actual type
* Logic is pretty straitforward
* - if ['kind'] is 'string' -> string
* - if ['kind'] is 'number' -> number
*/
type TranslateType<T extends { kind: PropertyType }> =
T['kind'] extends 'string'
? string
: T['kind'] extends 'number'
? number
: never;
type GenerateData<T extends Model> =
/**
* Iterate throus model data structure
*/
{
/**
* If ['optional'] exists and it is true
* Clone same data structure {kind:string, default:string}
* into nested property, make it partial and translate 'string' to string
*/
[K in keyof T]: T[K] extends { optional: true } ? {
-readonly [P in K]?: TranslateType<T[K]>
} : {
/**
* Do same as above but without making data optional
*/
-readonly [P in K]: TranslateType<T[K]>
}
};
/**
* UnionToIntersection -> converts union to UnionToIntersection
* Values -> obtain all nested properties as a union
*/
type ModelInstance<T extends Model> =
UnionToIntersection<Values<GenerateData<T>>>
const model = {
str: {
kind: 'string',
default: 'abc'
},
num: {
kind: 'number',
optional: true
}
} as const;
type Result = ModelInstance<typeof model>
Playground
Second
interface OptionRequired {
type: 'string' | 'number'
optional: boolean
}
interface OptionPartial {
type: 'string' | 'number'
}
type Option = OptionPartial | OptionRequired
/**
* Translates string type name to actual type
* Logic is pretty straitforward
*/
type TranslateType<T extends Option> =
T['type'] extends 'string'
? string
: T['type'] extends 'number'
? number
: never;
/**
* Check if optional exists
* if false - apply never, because union of T|never produces t
* if true - apply undefined
*/
type ModifierType<T extends Option> =
T extends { optional: true }
? undefined
: never
/**
* Apply TranslateType 'string' -> string
* Apply ModifierType {optional:true} -> undefined
*/
type TypeMapping<T extends Option> = TranslateType<T> | ModifierType<T>
/**
* Apply all conditions to each option
*/
type Mapping<T> = T extends Record<string, Option> ? {
-readonly [Prop in keyof T]: TypeMapping<T[Prop]>
} : never
type Data<Options> = Mapping<Options>
const model = {
a: {
type: 'string',
optional: true,
},
b: {
type: 'number',
optional: false
},
c: {
type: 'string',
},
} as const
type Result = Data<typeof model>
declare var x:Result
type a = typeof x.a // string | undefined
type b = typeof x.b // number
type c = typeof x.c // string
Playground
Personaly, I think that it is better to use required
property instead of optional
and accordingly reverse the boolean flags.
I mean to use {required:true}
instead of {optional: false}
.
It is more readable, but again, it is only my opinion.
Please take a look at this question. This case is VERY similar to yours