You can't type a whole path (path: string | string[]) but you can ensure that path is a property of T and value is of the same type as the path property:
export function append<
T extends Entity,
TKey extends keyof T,
>(state: Collection<T>, entityId: EntityId, path: TKey, value: T[TKey]): Collection<T> {
return state;
}
const commentWithLike = append(commentState, 1, 'likedByIds', [555]) // OK
const commentWithLike2 = append(commentState, 1, 'likedByIds', '555') // error
const commentWithLike3 = append(commentState, 1, 'likedByIds', 555) // error
Notice that I did not use S extends Collection<T>. This is because there are limitations in the way typescript infers generic parameters, so using S as you did in the function signature, would have cause T to always be inferred as Entity not Comment.
While this may be good enough for some use cases if you want to have a specific type for the collection you can do one of two things:
In typescript 2.8 (unreleased at the time of writing, scheduled for March 2018, but you can get it using npm install -g typescript@next) you can conditional types to extract the entity type:
export function append<
S extends Collection<any>,
T = S extends Collection<infer U> ? U : never,
TKey extends keyof T = keyof T,
>(state: S, entityId: EntityId, path: TKey, value: T[TKey]): S {
return state;
}
Or before ts 2.8, you could declare the append method on the Collection removing the need to infer S
export interface Collection<T extends Entity> extends Object {
readonly entities: { [key: string]: T };
readonly ids: EntityId[];
append<TKey extends keyof T>(entityId: EntityId, path: TKey, value: T[TKey]): this
}
const commentWithLike = commentState.append(1, 'likedByIds', [555])
Edit - Support for paths
Took some time to get it to work properly but here is a workable solution:
type PathHelper<T> = { <TKey extends keyof T>(path: TKey): PathHelper<T[TKey]>; Value?: T; };
type Path<TSource, TResult> = { (source: TSource): TResult, fullPath: string[] };
function path<TSource, TResult>(v: (p: PathHelper<TSource>) => PathHelper<TResult>): Path<TSource, TResult> {
let result: string[] = [];
function helper(path: string) {
result.push(path);
return helper;
}
v(helper);
return Object.assign(function (s: TSource) { throw new Error("Do not call directly, use path property") }, {
fullPath: result
});
}
type CollectionType<S> = S extends Collection<infer U> ? U : never;
export function append<S extends Collection<any>,
TValue>(state: S,
entityId: EntityId,
path: Path<CollectionType<S>, TValue>,
value: TValue): S {
console.log(path.fullPath);
return state;
}
//Usage:
interface Comment extends Entity {
text: string;
comment?: Comment; // added for nested object example
likedByIds: number[]
}
interface CommentState extends Collection<Comment> { }
const comment: Comment = { id: 1, text: 'hello', likedByIds: [] }
const commentState: CommentState = {
entities: {
1: { id: 1, text: 'hello', likedByIds: [] }
},
ids: [1]
}
const commentWithLike2 = append(commentState, 1, path(v => v("comment")("likedByIds")("length")), 5)
Notes
The path function takes a function in from which you will have to return the path by calling the parameter which is a function returning a function. Each time you call it you navigate into the object.
The path function returns a Path object, which has a call signature, and a fullPath property. The fullPath property should be used, the function signature should be ignored. Typescript does an interesting form of back inference when a function returns a function, and this function signature that Path has, saves us from having to specify a type parameter to path, and allows the compiler to infer the starting type for path based on the parameters to append
You could keep a simple signature to append that takes a string for simple paths.
export type EntityId = string;but you usenumberas ids