Typescript can prevent you from using indexes on the wrong array

Here's an idea that I've been toying around with. It's possible in TypeScript to create an index to a data structure that is only valid for that data structure. This method requires no runtime checks: it's verified completely at compile time!

I thought some people here may be interested in the pattern, and there are probably ways to refine this to be more ergonomic that I haven't thought of. This pattern takes inspiration from Rust's lifetimes and Haskell's ST

// An Array has a type and an Id
type IndexedArray<T, Id> = {
    data: T[],
    id: Id
};

// An index has an id, the id of the index must match that of the array
type Index<Id> = {
    index: number,
    id: Id,
}

// I can create an index with the id of the array
const push = <T, Id>(arr: IndexedArray<T, Id>, value: T): Index<Id> => {
    const { data, id } = arr;
    return {
        index: data.push(value) - 1,
        id
    };
};

// I can use an ondex on an array if it has the same id
const getIndex = <T, Id>(arr: IndexedArray<T, Id>, index: Index<Id>): T => {
    return arr.data[index.index];
}

// The trick here is a generic parameter inside the function
const withArray = <T, R>(callback: <Id>(array: IndexedArray<T, Id>) => R ): R => {
    return callback({
        data: [],
        // id is actually always null, but the type signatures prevent
        // this from being unified
        id: null
    });
}

withArray(<Id1>(a1: IndexedArray<number, Id1>) => 
    withArray(<Id2>(a2: IndexedArray<number, Id2>) => {
        // within this block, indexes can only be used on the array they
        // were created with
        const index1 = push(a1, 5);
        const index2 = push(a2, 3);

        getIndex(a1, index2);       // Error, Id2 not assignable to Id1
        getIndex(a2, index1);       // Error, Id1 not assignable to Id2

        const val1 = getIndex(a1, index1);
        const val2 = getIndex(a2, index2);
    })
);

EDIT: Made the code more obvious, a1 and a2 can both contain the same type

Posted Jun 5, 2021, 11:26 PM UTC · edited

2825

This just seems like an object with more steps.

31
pilotInPyjamasOP•

What do you mean?

3

Well basically you have a variable and you want to get a value out of it, and the way this is designed you have to use a special method to get the value out. You also need to use a special method to set the value.

Just simplify it and use an object with properties. If you don't know how many of the values you will have then use an array and then you don't access it by an arbitrary index like you do in your example.

6
pilotInPyjamasOP•

I think you may be missing the point. The idea of TypeScript is to prevent mistakes. Say you have two arrays of unrelated things:

const hats = ['top hat', 'baseball cap', 'beanie', 'wide brim'];
const plants = ['cactus', 'oak', 'jarrah', 'grass'];

It doesn't matter what they are, as long as they are unrelated. Say you use a function that returns an index into the array, like indexOf

const hatIndex = hats.indexOf('baseball cap');

There is nothing stopping you from using the hatIndex on the array of plants, even though that is completely wrong:

console.log(plants[hatIndex]); // absolute nonsense

The idea behind the original post is to try to prevent that kind of mistake from happening. The original code prevents you from using an index from one array on another array by mistake, even if they have the same type. I used the "special methods" to set and get a value because it was shorter to write than a full class.

4
gyzerok•

It is nice that TypeScript allows to achieve this level of safety. Though it feels to me like complicating a simple thing. It doesn’t look like the benefits are worth the complexity.

23
Fizzyfloat•

The goal of typescript is not to give developers tools to combat anti-patterns like you're suggesting, it's to help them scale well-built patterns. Your code is over-engineered and no company would enjoy seeing it in a pull request

1
jesseschalken•

Objects do not provide a type variable for the concrete type, so there is no type variable to use to match each index with the array/object it is an index for.

The workaround is to use a continuation to turn the existential type (the object) into a parametric type (generic) of the continuation, which is what has been done here.

2
lifeeraser•

Neat idea. Makes me want to learn Haskell more.

Have you considered using a branded type as the index instead of an actual object?

5
pilotInPyjamasOP•

If the type is not used in the type definition, then this trick doesn't work. Typescript infers that the index in a number for all Id, and doesn't check to see that that the ids match.

Ideally, Typescript would support zero sized types, but I don't think this is happening anytime soon.

2
lloyd08•

I believe they are talking about something like this, which removes the additional object allocation around the index:

// An Array has a type and an Id
type IndexedArray<T, Id> = {
    data: T[],
    id: Id
};

type Index<Id> = number & { __brand: Id };

// I can create an index with the id of the array
const push = <T, Id>(arr: IndexedArray<T, Id>, value: T): Index<Id> => {
    return arr.data.push(value) - 1 as Index<Id>;
};

// I can use an ondex on an array if it has the same id
const getIndex = <T, Id>(arr: IndexedArray<T, Id>, index: Index<Id>): T => {
    return arr.data[index];
}
3
pilotInPyjamasOP•

That makes a lot of sense. I wonder if you can remove the id on the array too

1
lloyd08•

Yeah, you can do something like type IndexedArray<T, Id> = T[] & {__brand: Id}, I just tend to be more hesitant to brand non-primitives, because __brand is added to the object properties, and has the potential to confuse the consumer when they see that property available in their IDE autocomplete. Alternatively, you can declare a unique symbol as the key, but some folks argue it adds a layer of readability confusion (and yet it removes IDE autocompletions... so YMMV):

declare const INDEX_BRAND: unique symbol; 
type Index<Id> = number & { [INDEX_BRAND]: Id };
4
jesseschalken•

You can do this in any language with parametric polymorphism (using a continuation to turn it into existential polymorphism).

It is a lot more ergonomic in Scala using path-dependent types.

4
pilotInPyjamasOP•

Not all languages that support parametric polymorphism support rank 2 polymorphism. I was surprised Typescript did actually.

2
jesseschalken•

Not all languages that support parametric polymorphism support rank 2 polymorphism.

While I'm sure that's true, I'm struggling to think of an example. Even Java supports this with a SAM interface whose method is generic, and C++ has "template templates" that will let you pass a template lambda to a template.

1
pilotInPyjamasOP•

Haskell does not have rank 2 polymorphism be default, it has to be enabled as a compiler feature. the forall keyword is unavailable until you do so.

Rust does not allow this as structs are unboxed. A method that returns a parameterized type that is not known until runtime is unsized so machine code cannot be generated. A method like that cannot be made into a trait object. These kind of generative types have to be implemented using lifetimes instead.

1
BrunchWithBubbles•

I don’t know if Typescript supports rank 2 polymorphism. But it’s not using rank 2 polymorphism in this example. The id types are given explicitly in the call to withArray. That is, they are not existentials but concrete types Id1 and Id2 (whatever they are defined to be), and there is therefore only 1 rank of polymorphism. You could write and type this program I Haskell any get the expected type errors without rank 2 types only using type annotations to make sure Id1 and Id2 are different just like the typescript program does.

1
lloyd08•

The id types are given explicitly in the call to withArray. That is, they are not existentials but concrete types Id1 and Id2 (whatever they are defined to be)

You can replace the call with completely inferred types and you'll get the correct error:

withArray((a1) => 
    withArray((a2) => {
        // within this block, indexes can only be used on the array they
        // were created with
        const index1 = push(a1, 5);
        const index2 = push(a2, 3);

        getIndex(a1, index2);       // Error, Id not assignable to Id
        getIndex(a2, index1);       // Error, Id not assignable to Id

        const val1 = getIndex(a1, index1);
        const val2 = getIndex(a2, index2);
    })
);

Argument of type 'Index<Id>' is not assignable to parameter of type 'Index<Id>'. Type 'Id' is not assignable to type 'Id'. Two different types with this name exist, but they are unrelated.

2
BrunchWithBubbles•

Huh neat. That would be rank 2 polymorphism then.

1
pilotInPyjamasOP•

I think you're mistaken, the equivalent code in Haskell is the following:

{-# LANGUAGE Rank2Types #-}

data List id a = List id [a]
data Index id = Index id Int

push :: List id a -> a -> (List id a, Index id)
push (List id xs) x = (List id (x: xs), Index id (length xs))

get :: List id a -> Index id -> a
get (List _ (x: _)) (Index _ 0) = x
get (List id (_: xs)) (Index _ n) = get (List id xs) (Index id (n - 1))

withArray :: (forall id. List id a -> b) -> b
withArray f = f (List () [])

wat = withArray (\a1 -> withArray (\a2 -> let
    (a1', index1) = push a1 5
    (a2', index2) = push a2 3

    -- bad1 = get a1' index2
    -- bad2 = get a2' index1

    val1 = get a1' index1
    val2 = get a2' index2
    in (val1, val2)
    ))

The program will fail to compile when you remove the forall from withArray:

withArray :: (List id a -> b) -> b
withArray f = f (List () [])

The error is:

Couldn't match type ‘id’ with ‘()’
1
BrunchWithBubbles•

I imagined you’d use a phantom type variable for the indie or put undefined instead of () for the index in the definition of withArray. In any case, you’re right I was mistaken. There should be a forall id inside the callback type to be equivalent to the typescript signature.

2
NewLlama•

These are called nominal types and are in late stages of proposal: https://github.com/Microsoft/TypeScript/issues/202

2
csorfab•

late stages of proposal

what are you basing this claim on? The linked issue and related issues/PR's are pretty much dead.

2
pilotInPyjamasOP•

Whilst nominal types will allow me not to use a useless null value in the indices or array, it doesn't solve the problem: two arrays of the same type can be indexed using the keys of the other.

This problem requires the use of generativity: the ability to create a new type at runtime, one for each instance of a data structure.

1
[deleted]•

[deleted]

1