1

I have a function with the following signature, which is a helper function that will append a value to a list property on an

export function append<
    T extends Entity, 
    S extends Collection<T>
>(state: S, entityId: number, path: string | string[], value): S {}

It is based on the following two simple interfaces

export type EntityId = number;

/**
 * Interface for entities
 */
export interface Entity extends Object {
    readonly id: EntityId;
}

/**
 * Interface for collection-based state
 */
export interface Collection<T extends Entity> extends Object {
    readonly entities: { [key: string]: T };
    readonly ids: EntityId[];
}

Example usage would look like

interface Comment extends Entity {
    text: string
    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 commentWithLike = append(commentState, 1, 'likedByIds', 555)
commentWithLike // { id: 1, text: 'hello', likedByIds: [555] }

The goal is for the above, to enforce that the type of the likedById passed in conforms to the interface, ie. only allow numbers and fail if I for example tried to pass in the ID as a string "555"

Is this possible? Much appreciated

2
  • Your code does not compile, you define export type EntityId = string; but you use number as ids Commented Mar 19, 2018 at 9:05
  • Sorry, fixed now Commented Mar 19, 2018 at 9:10

1 Answer 1

2

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.

Sign up to request clarification or add additional context in comments.

4 Comments

Is there any solution (hack or not) to how we can have type-safe paths? Otherwise, this is perfect as I'm already using 2.8, thanks!
@Tarlen I created a solution for type safe paths, hope it helps and is not too esoteric to actually use, let me know what you think about it :)
@Tarlen I saw you added a new similar question for paths :) this was a bit to weird ?
Yes, I realised that I can can just hardcode the level of nesting and use the other approach for the path as I think it will lead to a cleaner API

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.