TS: Infer literal typing based on chained methods - javascript

There is only one thing left for me to unblock my flow and publish the first trial version of my form validation lib.
I have the following code (of course I'm omitting a lot of things so it doesn't get too big)
interface Validation {
name: string
message: string
params?: Record<string, any>
test: (value: any, params?: any) => boolean
}
class MixedSchema<T> {
type!: T
validations: Validation[] = []
required(message?: string) {
this.validations.push({
name: 'required',
message: message,
test: (value: any) => {
return value === '' ? false : true
},
})
return this
}
oneOf(arrayOfValues: any[], message?: string) {
type Params = { arrayOfValues: any[] }
this.validations.push({
name: 'oneOf',
params: { arrayOfValues },
message: message,
test: (value: any, params: Params) => {
return params.arrayOfValues.includes(value)
},
})
return this
}
}
class StringSchema extends MixedSchema<string> {
email() {
// this.validations.push({ ... })
return this
}
maxWords(maxWords: number, message?: string) {
// this.validations.push({ ... })
return this
}
}
class NumberSchema extends MixedSchema<number> {
min(min: number, message?: string) {
// this.validations.push({ ... })
return this
}
round(type: 'toUp' | 'toDown' | 'closer') {
// this.validations.push({ ... })
return this
}
}
const schema = {
string() {
return new StringSchema()
},
number() {
return new NumberSchema()
},
// array, file, etc..
}
const form = {
email: schema.string().required(),
age: schema.number().min(18),
gender: schema.string().oneOf(['male', 'female']).required(),
color: schema.string().oneOf(['red', 'blue', 'green']),
}
type InferType<T> = {
[P in keyof T]: T[P] extends MixedSchema<infer TS> ? TS : never
}
type Form = InferType<typeof form>
So I get the following result
But I need to get the most real typing possible, I mean, similar to the schema defined for form, example
interface HowNeedsToBe {
email: string
age: number | undefined
gender: 'male' | 'female'
color: 'red' | 'blue' | 'green' | undefined
}
I believe that the logic is something like, in the absence of the required, puts an undefined and if there is oneOf, substitutes the argument with the T of the MixedSchema<T>, but I don’t know how to send this back to up to MixedSchema<T>, actually I think this logic is a mess.
I already researched about map and generics in typescript, but I confess that when it comes to putting it into practice, nothing good comes out.
Here's the TS playground if you want to try.

Conceptually you want the required() and oneOf() methods to narrow T; this would be easy enough to give types to (although you need type assertions to avoid compiler errors, since the compiler can't verify that you have actually done the requisite narrowing). So required(), called on a MixedSchema<T>, should return a MixedSchema<Exclude<T, undefined>> (using the Exclude utility type to remove undefined from any union members of T). And oneOf() should be generic in the element type U of the elements of arrayOfValues, and should return a MixedSchema<U | Extract<undefined, T>> (using the Extract utility type to keep undefined if the T can be undefined).
Here's how it might look (implementations elided for brevity):
declare class MixedSchema<T> {
type: T
validations: Validation[];
required(message?: string): MixedSchema<Exclude<T, undefined>>;
oneOf<U extends T>(arrayOfValues: U[], message?: string):
MixedSchema<U | Extract<undefined, T>>
}
Unfortunately the fact that you are subclassing MixedSchema is complicating matters; you want to say that, for example, a StringSchema should stay a StringSchema of some kind after calling required(); it should not be widened back to MixedSchema:
declare class StringSchema<T extends string | undefined> extends MixedSchema<T> {
email(): this;
maxWords(maxWords: number, message?: string): this;
}
declare class NumberSchema<T extends number | undefined> extends MixedSchema<T> {
min(min: number, message?: string): this;
round(type: 'toUp' | 'toDown' | 'closer'): this;
}
const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // MixedSchema<string>
t.email(); // error! Property 'email' does not exist on type 'MixedSchema<string>';
So we will need something more complicated.
The "right" answer here is to use so-called higher-kinded types of the sort requested (but not implemented) in microsoft/TypeScript#1213. You'd like to say something like: MixedSchema<T>'s required() method should return this<Exclude<T, undefined>>, where you are somehow treating this like a type that takes a generic parameter. So if this is a StringSchema<T>, then it should be StringSchema<Exclude<T, undefined>>. But there's no direct support for this.
Instead we need to simulate it, and all such simulations will involve some amount of "registering" the types we'd like to be able to treat like a generic-of-a-generic:
type Specify<C extends MixedSchema<any>, T> =
C extends NumberSchema<any> ? NumberSchema<Extract<T, number | undefined>> :
C extends StringSchema<any> ? StringSchema<Extract<T, string | undefined>> :
MixedSchema<T>;
We've listed out all the subclasses of MixedSchema that we care about and described how to specify their type parameters. So while we can't write this<Exclude<T, undefined>>, but we can write Specify<this, Exclude<T, undefined>> and have the same effect.
Here's the new implementation of MixedSchema:
declare class MixedSchema<T> {
type: T
validations: Validation[];
required(message?: string): Specify<this, Exclude<T, undefined>>;
oneOf<U extends T>(arrayOfValues: U[], message?: string):
Specify<this, U | Extract<undefined, T>>
}
And we can verify that it now behaves appropriately in subclasses:
const s = new StringSchema() // StringSchema<string | undefined>
s.email(); // okay
const t = s.required(); // StringSchema<string>
t.email(); // okay
Let's make sure that the types are inferred as you'd like:
const form = {
email: schema.string().required(),
age: schema.number().min(18),
gender: schema.string().oneOf(['male', 'female']).required(),
color: schema.string().oneOf(['red', 'blue', 'green']),
}
/* const form: {
email: StringSchema<string>;
age: NumberSchema<number | undefined>;
gender: StringSchema<"male" | "female">;
color: StringSchema<"red" | "blue" | "green" | undefined>;
} */
That's a good sign; the generic type parameter for each field has been specified the right way. And thus your InferType should be able to grab those fields types:
type Form = InferType<typeof form>
/* type Form = {
email: string;
age: number | undefined;
gender: "male" | "female";
color: "red" | "blue" | "green" | undefined;
} */
Looks good!
Playground link to code

Related

Typescript. Some object keys in array with foreach method and rewrite keys

I have this object:
const task = ref<Task>({
name: '',
description: '',
type: undefined,
level: 'tactic',
participants: undefined,
stages: undefined,
});
export interface Task extends CommonEntity {
description?: string;
type?: TaskType;
level?: EntityLevel;
participants?: number;
stages?: TaskTypeStage[];
questions?: TaskQuestion[];
materials?: TaskMaterial[];
indicators?: TaskIndicator[];
duration?: number;
images?: [];
program_id?: number;
roles?: TaskRole[];
tables?: TaskTable[];
competences?: TaskCompetence[];
task_type_id?: number;
}
export interface CommonEntity {
id?: number;
name?: string;
created_at?: string;
updated_at?: string;
errors?: ApiErrors;
delete?: boolean;
isTemporaryIdAdded?: boolean;
}
in some method I want to change task objects.
function handleSubmit() {
task.value.materials = removeTemporaryIdFromArray<TaskMaterial>(task.value.materials);
task.value.questions = removeTemporaryIdFromArray<TaskQuestion>(task.value.questions);
task.value.roles = removeTemporaryIdFromArray<TaskRole>(task.value.roles);
task.value.tables = removeTemporaryIdFromArray<TaskTable>(task.value.tables);
}
export function removeTemporaryIdFromArray<T>(
entity: TaskMaterial[] | TaskQuestion[] | TaskRole[] | TaskTable[] | undefined
) {
if (entity) {
return entity
.filter((item) => !item.delete || !item.isTemporaryIdAdded)
.map((item) => Object.assign({}, removeTemporaryId<T>(item)));
}
}
export function removeTemporaryId<T>(item: CommonEntity): T {
const { id, isTemporaryIdAdded, ...rest } = item;
return isTemporaryIdAdded ? (rest as T) : { id, ...(rest as T) };
}
I'm watching a way to change function handleSubmit without write four times task.value[key] = removeTemporaryIdFromArray. To something with ['objectKey1', 'objectKey2'].forEach(key => object[key] = changeFunction(object[key]) )
I'm cheating and write function below. And ts don't see mistakes
(['materials', 'questions', 'roles', 'tables'] as (keyof typeof task.value)[]).forEach((key) => {
task.value[key] = removeTemporaryIdFromArray<typeof key>(task.value[key] as TaskMaterial[]) as undefined;
});
What I don't understand is why task.value[key] is waiting undefined as value.
I understand, that solution it is not quite right.
Can you suggest a better solution?
I think the affectation is not ok
task.value[key] = removeTemporaryIdFromArray<typeof key>(task.value[key] as TaskMaterial[]) as undefined;
because removeTemporaryIdFromArray can not choose between various types (TaskMaterial[] | TaskQuestion[] | TaskRole[] | TaskTable[] | undefined) when set task.value[key].
I think you can work around with removeTemporaryIdFromArray_InPlace.
Here is my proposal (little verbose may be), and it is difficult to test without context. But typescript say ok.
// helper type
type Writable<T> = { -readonly [K in keyof T]: T[K] };
// your properties to loop on
const KeyNames = ["materials", "questions", "roles", "tables"] as const;
// type helper with only the 'good' keys, never undefined (but we do not care.)
type SubPropertyTask = Required<Pick<Task, typeof KeyNames[number]>>;
// your properties to loop on transformed in an array of keys, that you can loop on really.
const WriteableKeyNames = KeyNames as Writable<typeof KeyNames> as Array<keyof SubPropertyTask>;
// your new submit
function handleSubmitV3() {
// loop on key
WriteableKeyNames.forEach((key: keyof SubPropertyTask) => {
// the call wich is not so trivial
// < type of first element of the array (because we know its array)
// need also type of the key, taht can not be infered.
removeTemporaryIdFromArrayInplace<SubPropertyTask[typeof key][0], typeof key>(task[key], key);
});
}
// T might be written like this : T extends TaskQuestion | TaskMaterial | TaskRole | TaskTable
// but if you need to add other property, name should be enougth
export const removeTemporaryIdFromArrayInplace = <T, E extends keyof SubPropertyTask>(entity: Array<T> | undefined, e: E): void => {
// do your own filter/
const filteredEntity = entity?.filter((taskObject: T) => taskObject);
// set up the new value, which would work, with this particlar cast.
// Because at this time typeof task[e] is same as T and as SubPropertyTask[E] | undefined
// ... I think.
task[e] = filteredEntity as SubPropertyTask[E] | undefined;
};

method that takes parameter of multiple type and decide the behavior at runtime

I have a method like this
square(num: number | string): number {
//
}
In this method I want to check the parameter's data type and invoke the logic accordingly. How do to that in TS?
Edit:
Specific types in question
export type KV<T extends string | number> = {
key: string;
val: T;
}
export type LogicalKV<T extends string | number> = {
logicalOperator: LogicalOperator
filters?: Filter<T>[];
logicalFilters?: LogicalFilter<T>[];
}
Function
class Foo {
read(filter: KV<string | number> | LogicalKV <string | number>): Promise<void> {
// if filter is of KV do this, else do that
}
}
Types are stripped at runtime unfortunately. But you could go the good old way of typeof
if (typeof num === "string") {/*it's string here*/} else {/*it's number here*/}
Typescript will also understand that and coerce to an appropriate type in each branch.
In case of custom types, like yours, it's going to be a bit different. Algebraic data types are involved. It's also not too pretty when you instantiate it, but you have to deal with passing runtime string "kinds" one way or another.
export type KV = {
kvField: string;
kind: "kv";
};
export type LogicalKV = {
logicalKvField: number;
kind: "logicalKv";
};
const KVInstance: KV = {
kind: "kv",
kvField: "foo"
};
const LogicalKVInstance: LogicalKV = {
kind: "logicalKv",
logicalKvField: 1
};
type FilterArg = KV | LogicalKV;
const fn = (f: FilterArg) => {
switch (f.kind) {
case "kv":
// ts knows it's KV
f.kvField;
return;
case "logicalKv":
// ts knows it's LogicalKV
f.logicalKvField;
return;
}
}

Using typescript generics properly

I have an interface of the following format which describes database methods like so:
export default interface IRepository {
createAndSave(data: ICreateUserDTO): Promise<User>
findById<T>({ id }: { id: number }): Promise<T | null> // right here
...
}
As you can see from the snippet above, findById method is meant to take in a type and return a resolved promise of type T or a null value. I go-ahead to implement this in a class like so.
class DatabaseOps {
private ormManager: Repository<User>
...
async findById<User>({ id }: { id: number }): Promise<User | null> {
const t = await this.ormManager.findOne({
where: { id },
})
return t
}
...
}
When I try to create the findById method like that, typescript gives this error of this format
Type 'import("../src/user/infra/typeorm/entities/User").default' is not assignable to type 'User'.
'User' could be instantiated with an arbitrary type which could be unrelated to 'import("../src/audience/infra/typeorm/entities/User").default'
I tried to use typescript assertion to override this error like so
class DatabaseOps {
private ormManager: Repository<User>
...
async findById<User>({ id }: { id: number }): Promise<User | null> {
const t = await this.ormManager.findOne({
where: { id },
})
return t as Promise<User> // here
}
...
}
but I still get the error, I am not really sure what to do from this point onward.
Here is what the User model definition looks like, I am making use of TypeORM
export default class User {
#PrimaryGeneratedColumn('uuid')
id: string
#Column({
type: 'json',
nullable: true,
})
data: object
#Column({ type: 'tinyint', default: 1 })
status: number
...
}
What could be the cause of this and how do I rectify it? Any help will be appreciated. Thank you very much!
The IRepository.findById method's type signature doesn't mean what you think it means.
When you write findById<T>, it means that the method promises to work with any type T. Whoever calls the method chooses which type it is. Kind of like this:
const r : IRepository = ...
const x = r.findById<User>( ... )
const y = r.findById<number>( ... )
consy z = r.findById<string>( ... )
... and so on
And since the caller of the method can choose any type, it means that the implementer of the method must implement it such that it can work with any type. So it can't be just User. It has to be any type, whatever the caller happens to choose.
Now, what you probably meant to do was to create not just a repository, but a repository of a certain thing. To do this, the generic parameter should be on the interface, not on the method:
export default interface IRepository<T, DTO> {
createAndSave(data: DTO): Promise<T>
findById({ id }: { id: number }): Promise<T | null>
...
}
Then you can implement IRepository<User, ICreateUserDTO> in your class:
class UserRepository {
...
async createAndSave(data: ICreateUserDTO): Promise<User> {
...
}
async findById({ id }: { id: number }): Promise<User | null> {
const t = await this.ormManager.findOne({
where: { id },
})
return t as Promise<User> // here
}
...
}

How can I generate a type of string literals from an objects keys and values returned by a function?

I am trying to create a type from a readonly object, i.e.
const Actions = {
'user.crud': ['user.create', 'user.read', 'user.update', 'user.delete'],
} as const
type ActionsType = keyof typeof Actions | typeof Actions[keyof typeof Actions][number]
The above works nicely and sets up the type ActionsType to the string literals ('user.crud', 'user.create', etc..).
However, the Actions object above is very simplistic, instead, I really need to generate the Actions via functions. When I port the above over to being generated by a function, i.e.
// set up a function to generate all actions for the passed role
function getActions (role: string): Record<string, string[]> {
return {
[`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`, `${role}.delete`],
}
}
// generate the Actions from a function
const ActionsFromFunction = {
...getActions('user'),
} as const
// set up the Actions from a readonly object with values generated by getActions()
type ActionsFromFunctionType = keyof typeof ActionsFromFunction | typeof ActionsFromFunction[keyof typeof ActionsFromFunction][number]
the type ActionsFromFunctionType is no longer set to the string literals. Instead it is set to: string | number and in turn type tests fail as any string is accepted.
I've put together a demo of the above:
Playground
Is there a way of generating the Actions object via a function, whilst still maintaining the string literals within the type?
Your goal is only achievable through typescript Template literal types. They are not supported in typescript 4.0, but will be available in 4.1 version.
This is how you could do it with typescript 4.1
type CrudOperations<ROLE extends string> = [`${ROLE}.create`, `${ROLE}.read`, `${ROLE}.update`, `${ROLE}.delete`];
type GetActionsResult<ROLE extends string> = string extends ROLE // check if we can infer type
? { [k: string]: string[] } // if type is not inferable
: { [K in `${ROLE}.crud`]: CrudOperations<ROLE> };
function getActions<ROLE extends string>(role: ROLE): GetActionsResult<ROLE> {
return {
[`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`, `${role}.delete`]
} as GetActionsResult<ROLE>;
}
// falls back to { string: string[] } structure
const actions = getActions('admin' as string);
// generate the Actions from a function
const ActionsFromFunction = {
...getActions('user'),
...getActions('orders'),
}
Playground link
You can also write getActions without explicit types by inference:
function getActions<K extends string>(role: K) {
const actions = {
[`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`,
`${role}.delete`],
} as const
return actions as Record<K,typeof actions[K]>
}
Test it:
const ActionsFromFunction = {
...getActions('user'),
}
// { user: readonly ["user.create", "user.read", "user.update", "user.delete"];}
const ActionsFromFunction2 = {
...getActions('user' as string),
}
// { [x: string]: readonly [`${string}.create`, ..., `${string}.delete`]; }
type ActionsFromFunctionType =
| keyof typeof ActionsFromFunction
| typeof ActionsFromFunction[keyof typeof ActionsFromFunction][number]
// "user" | "user.create" | "user.read" | "user.update" | "user.delete"
Note, that getActions won't be accurate, if your role has a union string type:
const ActionsFromFunction3 = {
...getActions('user' as 'user' | 'admin'),
} // { user: ...; admin: ...; }
You might write getActions as distributed conditional type, if needed:
return actions as K extends any ? Record<K,typeof actions[K]> : never
const ActionsFromFunction3 = {
...getActions('user' as 'user' | 'admin'),
}
/*
| { user: readonly ["user.create", "user.read", "user.update", "user.delete"];}
| { admin: readonly ["admin.create", "admin.read", "admin.update", "admin.delete"]; }
*/
Playground

An index signature parameter type cannot be a union type. Consider using a mapped object type instead

I'm trying to use the following pattern:
enum Option {
ONE = 'one',
TWO = 'two',
THREE = 'three'
}
interface OptionRequirement {
someBool: boolean;
someString: string;
}
interface OptionRequirements {
[key: Option]: OptionRequirement;
}
This seems very straightforward to me, however I get the following error:
An index signature parameter type cannot be a union type. Consider using a mapped object type instead.
What am I doing wrong?
You can use TS "in" operator and do this:
enum Options {
ONE = 'one',
TWO = 'two',
THREE = 'three',
}
interface OptionRequirement {
someBool: boolean;
someString: string;
}
type OptionRequirements = {
[key in Options]: OptionRequirement; // Note that "key in".
}
The simplest solution is to use Record
type OptionRequirements = Record<Options, OptionRequirement>
You can also implement it yourself as:
type OptionRequirements = {
[key in Options]: OptionRequirement;
}
This construct is only available to type, but not interface.
The problem in your definition is saying the key of your interface should be of type Options, where Options is an enum, not a string, number, or symbol.
The key in Options means "for those specific keys that's in the union type Options".
type alias is more flexible and powerful than interface.
If your type does not need to be used in class, choose type over interface.
In my case:
export type PossibleKeysType =
| 'userAgreement'
| 'privacy'
| 'people';
interface ProviderProps {
children: React.ReactNode;
items: {
// ↙ this colon was issue
[key: PossibleKeysType]: Array<SectionItemsType>;
};
}
I fixed it by using in operator instead of using :
~~~
interface ProviderProps {
children: React.ReactNode;
items: {
// ↙ use "in" operator
[key in PossibleKeysType]: Array<SectionItemsType>;
};
}
I had some similar problem but my case was with another field property in interface so my solution as an example with optional field property with an enum for keys:
export enum ACTION_INSTANCE_KEY {
cat = 'cat',
dog = 'dog',
cow = 'cow',
book = 'book'
}
type ActionInstances = {
[key in ACTION_INSTANCE_KEY]?: number; // cat id/dog id/cow id/ etc // <== optional
};
export interface EventAnalyticsAction extends ActionInstances { // <== need to be extended
marker: EVENT_ANALYTIC_ACTION_TYPE; // <== if you wanna add another field to interface
}
In my case I needed the properties to be optional, so I created this generic type.
type PartialRecord<K extends string | number | symbol, T> = { [P in K]?: T; };
Then use it as such:
type MyTypes = 'TYPE_A' | 'TYPE_B' | 'TYPE_C';
interface IContent {
name: string;
age: number;
}
interface IExample {
type: string;
partials: PartialRecord<MyTypes, IContent>;
}
Example
const example : IExample = {
type: 'some-type',
partials: {
TYPE_A : {
name: 'name',
age: 30
},
TYPE_C : {
name: 'another name',
age: 50
}
}
}
Instead of using an interface, use a mapped object type
enum Option {
ONE = 'one',
TWO = 'two',
THREE = 'three'
}
type OptionKeys = keyof typeof Option;
interface OptionRequirement {
someBool: boolean;
someString: string;
}
type OptionRequirements = { // note type, not interface
[key in OptionKeys]: OptionRequirement; // key in
}
edited
TL;DR: use Record<type1,type2> or mapped object such as:
type YourMapper = {
[key in YourEnum]: SomeType
}
I faced a similar issue, the problem is that the allowed types for keys are string, number, symbol or template literal type.
So as Typescript suggests, we can use the mapped object type:
type Mapper = {
[key: string]: string;
}
Notice how in a map object we are only allowed to use strings, number or symbol as keys, so if we want to use a specific string (i.e. emum or union types), we shouold use the in keyword inside the index signature. This is used to refer to the specific properties in the enum or union.
type EnumMapper = {
[key in SomeEnum]: AnotherType;
};
On a real life example, let say we want to get this result,
an object that both its keys, and its values are of specified types:
const notificationMapper: TNotificationMapper = {
pending: {
status: EStatuses.PENDING,
title: `${ENotificationTitels.SENDING}...`,
message: 'loading message...',
},
success: {
status: EStatuses.SUCCESS,
title: ENotificationTitels.SUCCESS,
message: 'success message...',
},
error: {
status: EStatuses.ERROR,
title: ENotificationTitels.ERROR,
message: 'error message...'
},
};
In order to achieve this with Typescript, we should create the different types, and then implement them in a Record<> or with a mapped object type:
export enum EStatuses {
PENDING = 'pending',
SUCCESS = 'success',
ERROR = 'error',
}
interface INotificationStatus {
status: string;
title: string;
message: string;
}
//option one, Record:
type TNotificationMapper = Record<EStatuses, INotificationStatus>
//option two, mapped object:
type TNotificationMapper = {
[key in EStatuses]:INotificationStatus;
}
Here I'm using enums, but this approach work both for enum and union types.
*NOTE-
a similar syntax using the parenthesis instead of square brackets (i.e. this (...) instead of this [...], might not show any error, but it's signify a completely different thing, a function interface, so this:
interface Foo {
(arg:string):string;
}
is actually describing a function signature such as:
const foo = (arg:string) => string;
I had a similar issue. I was trying to use only specific keys when creating angular form validators.
export enum FormErrorEnum {
unknown = 'unknown',
customError = 'customError',
}
export type FormError = keyof typeof FormErrorEnum;
And the usage:
static customFunction(param: number, param2: string): ValidatorFn {
return (control: AbstractControl): { [key: FormErrorEnum]?: any } => {
return { customError: {param, param2} };
};
}
This will allow for 1 - X number of keys to be used.

Categories

Resources