TypeScript Template Literals break generic constraints - javascript

I am trying to write a Sub-Pub class that supports events AND sub-events denoted by the following syntax:
publisher.on("message:general", ... ) // subscribe to all messages
publisher.on("message", ... ) // subscribe to messages in general
To do this, I am using TypeScript template literals.
The issue is that while it broadly works, it seems to break generic constraints. Am I doing something wrong?
Here is what it looks like so far:
TS Playground link for convenience
interface ChatEvents {
connect: {
user: string;
};
disconnect: {
user: string;
reason: "banned" | "timeout" | "leave";
};
}
declare type ChatEvent = keyof ChatEvents;
interface SubEvents extends Record<ChatEvent, string> {
connect: "general" | "watercooler" | "lobby";
disconnect: "voice" | "text";
}
declare type EventWithSubEvent<T extends ChatEvent> = `${T}${
| ""
| `:${SubEvents[T]}`}`;
// "connect" | "connect:general" | "connect:watercooler" | "connect:lobby"
declare type ChatConnectEvent = EventWithSubEvent<"connect">;
// "disconnect" | "disconnect:voice" | "disconnect:text"
declare type ChatDisconnectEvent = EventWithSubEvent<"disconnect">;
// So far so good!
// Let's write some generics:
declare function subscribeToEvent<E extends ChatEvent>(
event: E,
callback: (payload: ChatEvents[E]) => void
): void;
subscribeToEvent("connect", (payload) => {
// Correctly extracts matching type
type TPayload = typeof payload; // { user: string; }
});
declare function subscribeToSubEvent<E extends ChatEvent>(
event: EventWithSubEvent<E>,
callback: (payload: ChatEvents[E]) => void
): void;
subscribeToSubEvent("connect:general", (payload) => {
// Extracts all possible payload types instead of only the `connect` one
/*
{
user: string;
} | {
user: string;
reason: "banned" | "timeout" | "leave";
}
*/
type TPayload = typeof payload;
});
subscribeToSubEvent("connect:voice", () => {}) // Should fail but doesn't. `:voice` is a subevent of `disconnect`, not `connect`.

I think I found the solution.
The problem is that apparently template types do not distribute over unions
To work around the issue take a look at this simplified playground
The trick is to specify the template type with generic type extending from any (just to force the union distribution)
declare type EventWithSubEvent<T extends ChatEvent> = T extends any ? `${T}:${SubEvents[T]}` : never;
This is your fixed playground

Generally I've found that the less typescript has to work to deduce a generic type from arguments, the more generics just work (especially for providing decent intellisense aid). In this case if you setup the generic to be the exact text passed as the first argument (constrained to all valid options) then use a helper type to extract just the 'connect' | 'disconnect' part to use in the callback it will work much smoother. (playground)
interface ChatEvents {
connect: {
user: string;
};
disconnect: {
user: string;
reason: "banned" | "timeout" | "leave";
};
}
interface SubEvents extends Record<keyof ChatEvents, string> {
connect: "general" | "watercooler" | "lobby";
disconnect: "voice" | "text";
}
type AllChatEvents = {[K in keyof ChatEvents]: K | `${K}:${SubEvents[K]}`}[keyof ChatEvents]
// helper to extract the 'connect'|'disconnect' from an event
type ExtractEvt<T extends AllChatEvents> = T extends keyof ChatEvents ? T : T extends `${infer A}:${string}` ? A : never
declare function subscribeToSubEvent<E extends AllChatEvents>(
event: E,
callback: (payload: ChatEvents[ExtractEvt<E>]) => void
): void;
subscribeToSubEvent("disconnect:voice", (payload) => {
console.log(payload.reason) // detects that 'disconnect' is the payload type
});
subscribeToSubEvent("connect:voice", () => {}) // fails properly
As a bonus, because the generic constraint is all valid strings to be passed intellisense will give very useful results compared to other generics setups where it can't figure out what the correct behaviour is until you've already typed it:

Related

Typescript: Having problems with mapping nested Record values wrapped in vue3 reactive proxy

UPDATED VERSION:
I have several implementations of a generic class, which is wrapped in the vue3 composition api function "reactive", which is basically a proxy which keeps track of any updates:
export declare function reactive<T extends object>(target: T): UnwrapNestedRefs<T>;
How can I implement a class so that the constructor takes an object of such implementations, and have such inner types unwrapped as parameter of a callback function which is the second argument of the constructor?
Like this:
const campaignWrapped = reactive(new MyWrapper<Campaign>(campaign));
const batchesWrapped = reactive(new MyWrapper<Batch[]>(batches));
new MyClass({ first: campaignWrapped, second: batchesWrapped }, (states) => {
states.first; // Should be of type Campaign
states.second; // Should be of type Batch[]
});
It seems like it's not possible to derive the type from the proxy object, because extends MyClass<infer U> ? ... doesn't match anymore. The only way around seems to be adding an interface to the Class and check for this interface instead. Even if I unwrap UnwrapNestedRefs first and infer it's type, I still can't get the real inner type.
OLD VERSION: (simplified)
(just for the context, as my wrong attempt might help someone to understand the difference between union and intersect - which wasn't clear to me)
I want to get the unboxed types in a callback, which have to be derived from generic types.
So far so good. It seems to work if I have only one record entry.
But as soon as there is another, it fails.
The resulting type looks ALMOST correct:
Record<"first", Campaign> | Record<"second", Batch[]>
How can I tell typescript to use intersect and not union? So the type would become:
Record<"first", Campaign> & Record<"second", Batch[]>
(BONUS QUESTION: Is it possible to get rid of the LSW interface and use the type of the class directly? For me the extends LazyStateWrapper<infer U> ? ... didn't work.)
Working example in typescriptlang.org playground here
And here is the code inline:
type UnwrapInner<X, S extends string> = X extends LSW<infer X>
? Record<S, X>
: never;
type UnwrapLazyStateWrapper<T> = T extends Record<infer S, infer U> ? (S extends string ? UnwrapInner<T[S], S> : never) : never;
export class TransientLazyState<X, T extends object> {
constructor(states: X, cb: (states: UnwrapLazyStateWrapper<X>) => T | undefined) {
console.log('todo');
}
}
interface LSW<T extends object> {
getStateClone(): T | undefined;
}
export class LazyStateWrapper<T extends object> implements LSW<T> {
private obj: T;
constructor(instance: T) {
this.obj = instance;
}
getStateClone(): T | undefined {
return {} as T;
}
}
type Campaign = { id: number; foo: string };
type Batch = { id: number; bar: string };
const campaign: Campaign = { id: 123, foo: 'x' };
const batches: Batch[] = [];
const campaignWrapped = new LazyStateWrapper(campaign);
const batchesWrapped = new LazyStateWrapper(batches);
const test1 = new TransientLazyState({ first: campaignWrapped }, (states) => {
const xy = states.first; // Yay. Works! Is of type "Campaign"
console.log(xy);
return undefined;
});
const test2 = new TransientLazyState({ first: campaignWrapped, second: batchesWrapped }, (states) => {
const xy = states.first; // Doesn't work anymore, once there are more records. But to me the type looks ALMOST correct. ERROR: Property 'first' does not exist on type 'Record<"first", Campaign> | Record<"second", Batch[]>'. Property 'first' does not exist on type 'Record<"second", Batch[]>'.(2339)
console.log(xy);
return undefined;
});

How to define a dynamic generic constraint in typescript

I have a abstract base class with a generic type parameter like this:
abstract class Base<T> {
constructor(protected value: T) {}
}
I inherit multiple classes from the base. For example:
class TBase extends Base<number> {
constructor(value: number) {
super(value);
}
}
class XBase extends Base<string> {
constructor(value: string) {
super(value);
}
}
Now I want to write a factory function that returns a new Base based on my input properties:
type ValidTypes = 'string' | 'integer' | 'decimal' | 'boolean' | 'datetime';
type validFor<T> = T extends 'string' | 'datetime'
? string
: T extends 'integer' | 'decimal'
? number
: T extends 'boolean'
? boolean
: never;
function getBase<T extends ValidTypes, P extends validFor<T>>(type: T, value: P): Base<P> {
switch(type) {
case 'number': new TBase(coerceNumber(value)); break;
...
}
}
When passing 'string' as first parameter, the second can only be of type string. For 'decimal', type P can only be a number.
But I have two problems. When calling the function like this:
getBase('string', '5');
It works, but it says that the signature is
function getBase<'string', '5'>(type: 'string', value: '5'): Base<'5'>
It don't understand why it's not resolving to string but instead to the value of value?
The other problem is, that when I return a new TBase() it states that it could also be a string or boolean:
"'number' is assignable to the constraint of type 'P', but 'P' could be instantiated with a different subtype of constraint 'string | number | boolean'."
I searched a lot about this, but couldn't get around it. Could someone explain to me why excactly this happens? Even when i explicit cast it to a Base it throws the error (return new TBase() as Base)
Another approach I tried was with using function overloads, but this looks kinda weird and not right:
getBase(type: 'decimal' | 'integer', value: number): Base<number>
getBase(type: 'string' | 'datetime', value: string): Base<string>
getBase(type: 'boolean', value: boolean): Base<boolean>
getBase(type: ValidTypes, value: number | boolean | string): Base<number | boolean | string> {
...
}
I want to something like this:
getBase('string', 'thisMustBeAString'); // => Base<string>;
getBase('decimal', 54 /* Must be a number */) // => Base<number>;
What am I missing? I'm quiet struggling with this for a long time now.. Thanks in advance
Edit:
Playground Link
I tried very hard to get your first approach to work in a fully type-safe manner without casts, but failed.
This question also deals with return types whose types depend on argument types, and several answers suggest to use overloading. As you've already found, that's one way to solve it – although the return type of your implementation should not be Base<number | string | boolean> but rather Base<number> | Base<string> | Base<boolean>.
If you're okay with changing the call syntax a bit, you can do this and still remain fully type safe:
// Deliberately leaving this untyped, so we get the most specific inferred type possible.
const factories = {
'integer': (value: number) => new TBase(value) as Base<number>,
'string': (value: string) => new XBase(value) as Base<string>,
}
const a: Base<string> = factories['string']('thisMustBeAString');
const b: Base<number> = factories['integer'](54);
const c: Base<number> = factories['integer']('thisWillNotCompile');
const d: Base<number> = factories['string']('neitherWillThis');
const e: Base<string> = factories[a.value]('norWillThis');
Some ways to extract types from this map:
// Extract the names of the types: 'integer' | 'string'.
type TypeName = keyof typeof factories;
// Extract the type of the factory function, for example (value: number) => Base<number>.
type FactoryType<T extends TypeName> = typeof factories[T];
// Extract the type of argument that goes into the factory.
type FactoryArgumentType<T extends TypeName> = Parameters<FactoryType<T>>[0];
// Extract the factory's return type.
// Currently always equal to its argument type, but it doesn't need to be.
type FactoryReturnType<T extends TypeName> = ReturnType<FactoryType<T>>;
Using these, you can in fact implement getBase in a rather ugly way:
function getBase<T extends TypeName>(type: T, value: FactoryArgumentType<T>): FactoryReturnType<T> {
return factories[type](value as never) as FactoryReturnType<T>;
}
I don't quite understand why the strange value as never is needed to make the compiler accept this, but it emits the right code in the end.

TypeScript: Detect generic return type

I want to create a core dialog class, so that when adding a new dialog, TS
will resolve a dialog type (and it's return types) automatically, basing on the input given. I was able to achieve most of it, but I failed when it comes to the return values.
Each dialog return a Promise, promise result should base on the dialog type passed, eg.
when T is PromptDialogOptions return Promise<string | number>,
when T is ConfirmDialogOptions return Promise<boolean>,
whereas when T is MessageDialogOptions then return Promise<void>.
My actual code for creating a dialog (I have marked the lines that produce errors and explained them below due to the length):
let dialogs: DialogOptions[] = [];
newDialog<T extends DialogOptions, R extends InnerDialogType<T>>(dialog: T) : Promise<R> => {
const promise = new Promise<R>(res => {
// Set dialog resolver
dialog.resolver = res; // error #1 (see below)
});
// Create dialog close handler
dialog.closeHandler = (result: R) => { // error #2 (see below)
// Resolve a promise
dialog.resolver(result);
// Close the dialog
// this.closeDialog(dialog);
};
// Store dialog
dialogs = [...dialogs, dialog];
return promise;
}
This code produces two errors:
#1 line dialog.resolver = res;
Type '(value?: R | PromiseLike | undefined) => void' is not
assignable to type '((value?: void | undefined) => void) | ((value?:
string | number | undefined) => void) | ((value?: boolean | undefined)
=> void) | undefined'. Type '(value?: R | PromiseLike | undefined) => void' is not assignable to type '(value?: void |
undefined) => void'.
#2 line dialog.closeHandler = (result: R) => {
Type '(result: R) => void' is not assignable to type '((result: void)
=> void) | ((result: string | number) => void) | ((result: boolean) => void)'.
There's clearly an issue with the wrong types being used for handling result in the BaseDialog.
Question
How can I make BaseDialog.resolver and BaseDialog.closeHandler accept the generic type of R, that is inferred depending on the dialog result type being passed?
Some examples of what I want to achieve:
const confirmDialog : ConfirmDialogOptions = {
title: "Hello",
message: "I'm stuck. Will you help?",
type: DialogType.DIALOG_CONFIRM
};
newDialog(confirmDialog);
Expected result: Promise<boolean>
const dialog = {
title: "Invalid",
message: "Invalid dialog is bad!",
type: DialogType.DIALOG_MESSAGE
}
newDialog(dialog);
Expected result: error, since dialog doesn't inherit from BaseDialog
const promptDialog : PromptDialogOptions = {
title: "Hello",
message: "Say hello",
maxLength: 10,
type: DialogType.DIALOG_PROMPT
};
newDialog(promptDialog);
Expected result: Promise<string | number>
All types used
export const enum DialogType {
DIALOG_MESSAGE,
DIALOG_CONFIRM,
DIALOG_PROMPT
}
export interface BaseDialog<T> {
title: string;
message: string;
type: DialogType;
resolver?: (value?: T) => void;
}
export interface MessageDialogOptions extends BaseDialog<void> { }
export interface ConfirmDialogOptions extends BaseDialog<boolean> { }
export interface PromptDialogOptions extends BaseDialog<string | number> {
maxLength: number;
}
// Union dialogs
export type DialogOptions = MessageDialogOptions | PromptDialogOptions | ConfirmDialogOptions;
Why not simply?
function newDialog<R>(dialog: BaseDialog<R>): Promise<R> {
...
This would make it clear to the compiler that R and T are for the same dialog subtype (which your current function declaration fails to express, causing the compiler error you mention).
If you chose the union type to enforce that the dialog is actually one of the known types, you can better accomplish this with overloads:
function newDialog(dialog: MessageDialog): Promise<void>;
function newDialog(dialog: ConfirmDialog): Promise<boolean>;
function newDialog(dialog: PromptDialog): Promise<string | number>;
function newDialog<R>(dialog: BaseDialog<R>): Promise<R> {
// implementation here
}
BTW, as a caller, I'd probably prefer separate named methods to specifying the type with an enum:
newConfirmDialog({
title: 'Really launch rockets?',
text: 'Once launched, they can not be recalled.'
})

typescript can I make this code more DRY?

I have a class of functions that I use as decorators:
interface SchemaDefinition {
type: any;
label?: string | Function;
optional?: boolean | Function;
min?: number | boolean | Date | Function;
max?: number | boolean | Date | Function;
minCount?: number | Function;
maxCount?: number | Function;
allowedValues?: any[] | Function;
decimal?: boolean;
exclusiveMax?: boolean;
exclusiveMin?: boolean;
regEx?: RegExp | RegExp[];
custom?: Function;
blackbox?: boolean;
autoValue?: Function;
defaultValue?: any;
trim?: boolean;
}
class Decorators {
static Type(value: Pick<SchemaDefinition, 'type'>) {
return SchemaDecorators.extendSchema('type', value)
}
static Min(value: Pick<SchemaDefinition, 'min'>) {
return SchemaDecorators.extendSchema('min', value)
}
static Max(value: Pick<SchemaDefinition, 'max'>) {
return SchemaDecorators.extendSchema('max', value)
}
....
}
This does not seem very DRY. Can it be improved?
TypeScript doesn't always make DRY very easy, especially since all type information is erased at runtime. That means sometimes the only way not to repeat yourself is to make a dummy runtime object whose type information can be inferred by the compiler.
Here's an attempt at being DRY, which you might decide is not worth it. First, we will need to loop over the keys of SchemaDefinition at runtime to create Decorators programmatically. I create a SchemaDefinitionClass with dummy undefined values so that we can get at those keys:
const ω: never = (void 0)!;
class SchemaDefinitionClass {
type: any = ω;
label?: string | Function = ω;
optional?: boolean | Function = ω;
min?: number | boolean | Date | Function = ω;
max?: number | boolean | Date | Function = ω;
minCount?: number | Function = ω;
maxCount?: number | Function = ω;
allowedValues?: any[] | Function = ω;
decimal?: boolean = ω;
exclusiveMax?: boolean = ω;
exclusiveMin?: boolean = ω;
regEx?: RegExp | RegExp[] = ω;
custom?: Function = ω;
blackbox?: boolean = ω;
autoValue?: Function = ω;
defaultValue?: any = ω;
trim?: boolean = ω;
}
interface SchemaDefinition extends SchemaDefinitionClass { }
You can verify that SchemaDefinition is defined as before. I guess setting all the values explicitly to undefined is repeating yourself, but at least it's a short repetition.
Oh, I don't know what SchemaDecorators.extendSchema() returns. I will make a stub return type for it; change it as you want:
type Foo<K,V> = {
foo: K;
bar: V;
}
declare const SchemaDecorators: {
extendSchema<K extends string, V>(k: K, v: V): Foo<K, V>;
}
Now instead of making Decorators a class with a bunch of static methods, I will make it an object with a bunch of method properties. It mostly amounts to the same thing and can be merged with a class if you need it.
const Decorators: {
[K in keyof SchemaDefinition]: (value: Pick<SchemaDefinition, K>) => Foo<SchemaDefinition, Pick<SchemaDefinition, K>>
} = {} as any;
Note! The above definition means that the static methods are lowercase with the same name as the keys of SchemaDefinition, not CamelCase. That's unavoidable unless you want to write out a mapping like {type: 'Type', ...} which I don't.
Okay, let's populate Decorators:
Object.keys(new SchemaDefinitionClass()).forEach(<K extends keyof SchemaDefinition>(k: K) => {
Decorators[k] = ((value: Pick<SchemaDefinition, K>) => SchemaDecorators.extendSchema(k, value)) as any;
});
So I think that will work for you, but it's pretty ugly. You might be happier just repeating yourself. It's up to you. Hope that helps! Good luck!

Typescript definition for union-type js

Would like to seek help writing d.ts file for https://github.com/paldepind/union-type
With the union type below,
let Maybe = Type({
Nothing: []
, Just: [Number]
})
I would love to see compiler error in case Maybe.Nothing() is mistyped as Maybe.None()
I tried to capture the keys from the object literal, but the compiler still fails to recognize Nothing and Just in the resulting type Maybe.
interface Obj {
prototype: any
case: (x: {[index: string]: (...args) => any}) => any
caseOn: (x: {[index: string]: (...args) => any}) => any
}
interface Union<T> {
(desc: T): T & Obj
}
var Type: Union<{[key: string]: any}>
export = Type
I think what you are looking for are Index Type, see the Advanced Types section of the handbook for more explanation on how they work.
I've also been making a declaration file for paldepind/union-type, but it's incomplete :
declare module 'union-type' {
type Constructors =
| StringConstructor
| NumberConstructor
| ArrayConstructor
| BooleanConstructor
| ObjectConstructor
| FunctionConstructor
export default function Type<
T extends {
[k: string]: (Constructors | ((arg?: any) => boolean | Constructors))[]
},
K extends keyof T
>(
desc: T
): {
// case(cases: { [k in K]: (...args: any[]) => void }, obj: any): void
[k in K]: (...args: any[]) => any
}
}
I'm also trying to find a workaround that library in TS...
If you are looking for a Maybe implementation here is one I wrote once upon a time
/**
* A Maybe implementation
* that is JavaScript first
* i.e. simple typed abstraction over null/undefined
*/
export class Maybe<T>{
private _value: T;
/** Based on value it will be Some or None */
constructor(value: T) {
this._value = value;
}
/** Shorthand for constructor */
static Some<T>(value: T): Maybe<T> {
if (value === null || value === undefined) {
throw new Error('value for some cannot be null or undefied');
}
return new Maybe(value);
};
static None<T>(): Maybe<T> {
return new Maybe(null);
};
get value(): T {
return this._value;
}
get isSome() {
return this._value !== null && this._value !== undefined;
}
get isNone() {
return !this.isSome;
}
map<U>(mapper: (now: T) => U): Maybe<U> {
if (this.isSome) {
return new Maybe(mapper(this._value));
}
else {
return new Maybe(null);
}
}
}
That said, I find it pretty useless, much simpler to be just aware of null/undefined and use valid property on your objects (more https://medium.com/#basarat/null-vs-undefined-in-typescript-land-dc0c7a5f240a)
More
That all said typescript is going to get first class nullability support https://github.com/Microsoft/TypeScript/pull/7140 and you will be able to do number | null | undefined and you would not be allowed to assign null to a number i.e. let foo:number = null; // Error foo is not nullable

Categories