Building a form config builder, want to infer types dynamically, here's the minimal reproduction code
interface Meta {
optional: boolean
}
class Base<Type = any> {
readonly _type!: Type
baseMeta: Meta
constructor() {
this.baseMeta = {
optional: false,
}
}
optional() {
this.baseMeta.optional = true
return this;
}
}
class FieldText extends Base<string> {
}
class FieldNumber extends Base<number> {
}
class Field {
static text() {
return new FieldText();
}
static number() {
return new FieldNumber();
}
}
const fields = {
fieldText: Field.text(),
fieldNumber: Field.number(),
fieldTextOptional: Field.text().optional(),
fieldNumberOptional: Field.number().optional(),
}
Given code above I want to achieve one of the following types, doesn't matter which one
type ExpectedType = {
fieldText: string;
fieldNumber: number;
fieldTextOptional: number | undefined;
fieldNumberOptional: number | undefined;
}
Also fine
type ExpectedTypeWithOptionalKeys = {
fieldText: string;
fieldNumber: number;
fieldTextOptional?: number;
fieldNumberOptional?: number;
}
I've gotten to infering types, but can't figure out why the optional property is not taken in consideration
type ExtractFieldType<O, T> = O extends true ? T | undefined : T;
type SchemaType<T extends Record<string, Base>> = {
[Property in keyof T]: ExtractFieldType<T[Property]['baseMeta']['optional'], T[Property]['_type']>;
};
type Schema = SchemaType<typeof fields>;
type Schema
results in:
type Schema = {
fieldText: string;
fieldNumber: number;
fieldTextOptional: string;
fieldNumberOptional: number;
}
this O extends true ? T | undefined : T
always returns T, doesn't take actual value of optional into consideration
EDIT:
after a bit of fumbling around with @kelly's answer for providing a value to the optional function here's the end result:
optional<T extends boolean>(value?: T): Base<Type, T> {
const optional = typeof value === 'boolean' ? value : true;
this.baseMeta.optional = optional as Optional;
return this as unknown as Base<Type, T>;
}
first you can't have a default value as then Typescript get's angry with:
Type 'boolean' is not assignable to type 'T'.
'boolean' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'boolean'.(2322)
So we simply make value optional
and then the rest works pretty much as Kelly explained
except for return this as unknown as Base<Type, T>;
if you remove the as unknown
part you get the following error:
Conversion of type 'this' to type 'Base<Type, T>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Type 'Base<Type, Optional>' is not comparable to type 'Base<Type, T>'.
Type 'Optional' is not comparable to type 'T'.
'Optional' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'boolean'.(2352)
Here's the final working in Typescript Playground
CodePudding user response:
The type of optional
in Meta
is always boolean
. You need to store the value in a generic to use later:
interface Meta<Optional extends boolean> {
optional: Optional;
}
class Base<Type = any, Optional extends boolean = false> {
readonly _type!: Type
baseMeta: Meta<Optional>
constructor() {
this.baseMeta = {
optional: false as Optional,
}
}
optional(): Base<Type, true> {
this.baseMeta.optional = true as Optional; // needs unsafe assert, or alternatively //@ts-ignore
return this as Base<Type, true>;
}
}
Then when you change it with optional
, trick TypeScript into believing you are returning Base<Type, true>
instead.
You only need to change this. Your original solution now works.
Addressing the followup in the comments:
optional<T extends boolean = true>(value: T = true): Base<Type, T> {
this.baseMeta.optional = value as Optional;
return this as Base<Type, T>;
}