How to declare a Fixed length Array in TypeScript

289.4k views Asked by At

At the risk of demonstrating my lack of knowledge surrounding TypeScript types - I have the following question.

When you make a type declaration for an array like this...

position: Array<number>;

...it will let you make an array with arbitrary length. However, if you want an array containing numbers with a specific length i.e. 3 for x,y,z components can you make a type with for a fixed length array, something like this?

position: Array<3>

Any help or clarification appreciated!

9

There are 9 answers

5
Nitzan Tomer On BEST ANSWER

The JavaScript array has a constructor that accepts the length of the array:

let arr = new Array<number>(3);
console.log(arr); // [undefined × 3]

However, this is just the initial size, there's no restriction on changing that:

arr.push(5);
console.log(arr); // [undefined × 3, 5]

TypeScript has tuple types which let you define an array with a specific length and types:

let arr: [number, number, number];

arr = [1, 2, 3]; // ok
arr = [1, 2]; // Type '[number, number]' is not assignable to type '[number, number, number]'
arr = [1, 2, "3"]; // Type '[number, number, string]' is not assignable to type '[number, number, number]'
1
Maroš Beťko On

For anyone needing more general solution than the one from @ThomasVo that correctly works with non-literal numbers:

type LengthArray<
        T,
        N extends number,
        R extends T[] = []
    > = number extends N
        ? T[]
        : R['length'] extends N
        ? R
        : LengthArray<T, N, [T, ...R]>;

I needed to use this type to also work correctly with unknown length arrays.

type FixedLength = LengthArray<string, 3>; // [string, string, string]
type UnknownLength = LengthArray<string, number>; // string[] (instead of [])
1
Thomas Vo On

With typescript v4.6, here's a super short version based on Tomasz Gawel's answer

type Tuple<
  T,
  N extends number,
  R extends readonly T[] = [],
> = R['length'] extends N ? R : Tuple<T, N, readonly [T, ...R]>;

// usage
const x: Tuple<number,3> = [1,2,3];
x; // resolves as [number, number, number]
x[0]; // resolves as number

There are other approaches that imposes the value of the length property, but it's not very pretty

// TLDR, don't do this
type Tuple<T, N> = { length: N } & readonly T[];
const x : Tuple<number,3> = [1,2,3]

x; // resolves as { length: 3 } | number[], which is kinda messy
x[0]; // resolves as number | undefined, which is incorrect
4
Tomasz Gawel On

The original answer was written some time ago, with typescript version 3.x. Since then the typescript version went as far as 4.94, some limitation of typescript has been lifted. Also the answer was modified due to some issues pointed in comments.

Original Answer

Actually, You can achieve this with current typescript:

type Grow<T, A extends Array<T>> = 
  ((x: T, ...xs: A) => void) extends ((...a: infer X) => void) ? X : never;
type GrowToSize<T, A extends Array<T>, N extends number> = 
  { 0: A, 1: GrowToSize<T, Grow<T, A>, N> }[A['length'] extends N ? 0 : 1];

export type FixedArray<T, N extends number> = GrowToSize<T, [], N>;

Examples:

// OK
const fixedArr3: FixedArray<string, 3> = ['a', 'b', 'c'];

// Error:
// Type '[string, string, string]' is not assignable to type '[string, string]'.
//   Types of property 'length' are incompatible.
//     Type '3' is not assignable to type '2'.ts(2322)
const fixedArr2: FixedArray<string, 2> = ['a', 'b', 'c'];

// Error:
// Property '3' is missing in type '[string, string, string]' but required in type 
// '[string, string, string, string]'.ts(2741)
const fixedArr4: FixedArray<string, 4> = ['a', 'b', 'c'];

At that time (typescript 3.x), with this approach it was possible to construct relatively small tuples of size up to 20 items. For bigger sizes it produced "Type instantiation is excessively deep and possibly infinite". This problem was raised by @Micha Schwab in the comment below. This made to think about more efficient approach to growing arrays which resulted in the Edit 1.

EDIT 1: Bigger sizes (or "exponential growth")

This should handle bigger sizes (as basically it grows array exponentially until we get to closest power of two):

type Shift<A extends Array<any>> = 
  ((...args: A) => void) extends ((...args: [A[0], ...infer R]) => void) ? R : never;

type GrowExpRev<A extends Array<any>, N extends number, P extends Array<Array<any>>> = A['length'] extends N ? A : {
  0: GrowExpRev<[...A, ...P[0]], N, P>,
  1: GrowExpRev<A, N, Shift<P>>
}[[...A, ...P[0]][N] extends undefined ? 0 : 1];

type GrowExp<A extends Array<any>, N extends number, P extends Array<Array<any>>> = A['length'] extends N ? A : {
  0: GrowExp<[...A, ...A], N, [A, ...P]>,
  1: GrowExpRev<A, N, P>
}[[...A, ...A][N] extends undefined ? 0 : 1];

export type FixedSizeArray<T, N extends number> = N extends 0 ? [] : N extends 1 ? [T] : GrowExp<[T, T], N, [[T]]>;

This approach allowed to handle bigger tuple sizes (up to 2^15), although with numbers above 2^13 it was noticable slow.

This approach had also a problem with handling tuples of any, never and undefined. These types satisfy the extends undefined ? condition (the condition used to test if the index is out of generated array), and so would keep the recursion going infinitely. This problem was reported by @Victor Zhou in his comment.

EDIT 2: Tuples of never, any, or undefined

The "exponential array growth" approach cannot handle tuples of any, never and undefined. This can be solved by first preparing the tuple of some "not controversial type" then rewriting the tuple with requested size to requested item type.

type MapItemType<T, I> = { [K in keyof T]: I };

export type FixedSizeArray<T, N extends number> = 
    N extends 0 ? [] : MapItemType<GrowExp<[0], N, []>, T>;

Examples:

var tupleOfAny: FixedSizeArray<any, 3>; // [any, any, any]
var tupleOfNever: FixedSizeArray<never, 3>; // [never, never, never]
var tupleOfUndef: FixedSizeArray<undefined, 2>; // [undefined, undefined]

In the meantime current typescript version become 4.94. It's time summarize and clean up the code.

EDIT 3: Typescript 4.94

The original FixedArray type may be now written as simple as:

type GrowToSize<T, N extends number, A extends T[]> = 
  A['length'] extends N ? A : GrowToSize<T, N, [...A, T]>;

export type FixedArray<T, N extends number> = GrowToSize<T, N, []>;

This can now handle sizes up to 999.

let tuple999: FixedArray<boolean, 999>; 
// let tuple999: [boolean, boolean, boolean, boolean, boolean, boolean, boolean,
// boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean,
// boolean, boolean, ... 980 more ..., boolean]

let tuple1000: FixedArray<boolean, 1000>;
// let tuple1000: any
// Error:
// Type instantiation is excessively deep and possibly infinite. ts(2589)

So we may add safe guard to return array of T if tuple size exceeds 999.

type GrowToSize<T, N extends number, A extends T[], L extends number = A['length']> = 
  L extends N ? A : L extends 999 ? T[] : GrowToSize<T, N, [...A, T]>;
export type FixedArray<T, N extends number> = GrowToSize<T, N, []>;

let tuple3: FixedArray<boolean, 3>; // [boolean, boolean, boolean]
let tuple1000: FixedArray<boolean, 1000>; // boolean[]

The "exponential array growth" approach can now handle up to 8192 (2^13) tuple size.

Above that size, it raises "Type produces a tuple type that is too large to represent. ts(2799)".

We can write it, including safe guard at size of 8192, as below:

type Shift<A extends Array<any>> = 
  ((...args: A) => void) extends ((...args: [A[0], ...infer R]) => void) ? R : never;

type GrowExpRev<A extends any[], N extends number, P extends any[][]> = 
  A['length'] extends N ? A : [...A, ...P[0]][N] extends undefined ? GrowExpRev<[...A, ...P[0]], N, P> : GrowExpRev<A, N, Shift<P>>;

type GrowExp<A extends any[], N extends number, P extends any[][], L extends number = A['length']> = 
  L extends N ? A : L extends 8192 ? any[] : [...A, ...A][N] extends undefined ? GrowExp<[...A, ...A], N, [A, ...P]> : GrowExpRev<A, N, P>;

type MapItemType<T, I> = { [K in keyof T]: I };

export type FixedSizeArray<T, N extends number> = 
  N extends 0 ? [] : MapItemType<GrowExp<[0], N, []>, T>;

let tuple8192: FixedSizeArray<boolean, 8192>;
// let tuple8192: [boolean, boolean, boolean, boolean, boolean, boolean, boolean, 
// boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean, boolean, 
// boolean, boolean, ... 8173 more ..., boolean]

let tuple8193: FixedSizeArray<boolean, 8193>; 
// let tuple8193: boolean[]
0
Sushruth On

A little late to the party but here is one way if you are using read-only arrays ([] as const) -

interface FixedLengthArray<L extends number, T> extends ArrayLike<T> {
  length: L
}

export const a: FixedLengthArray<2, string> = ['we', '432'] as const

Adding or removing strings in const a value results in this error -

Type 'readonly ["we", "432", "fd"]' is not assignable to type 'FixedLengthArray<2, string>'.
  Types of property 'length' are incompatible.
    Type '3' is not assignable to type '2'.ts(2322)

OR

Type 'readonly ["we"]' is not assignable to type 'FixedLengthArray<2, string>'.
  Types of property 'length' are incompatible.
    Type '1' is not assignable to type '2'.ts(2322)

respectively.

EDIT (05/13/2022): Relevant future TS feature - satisfies defined here

2
Anton S On
const texts: ReadonlyArray<string> & { length: 10 } = [
  'Thats it!',
] as const;

// Types of property  length  are incompatible.
// Type  1  is not assignable to type  10

0
Natan Salmon On

you can use this type here:

type ArrayWithLength<Len extends number, T extends unknown, Occ extends T[] = []> = Occ["length"] extends Len
   ? Occ
   : ArrayWithLength<Len, T, [T, ...Occ]>;

this type here uses recursion to create a type of an array with a fixed length.

use case example:

let arr:ArrayWithLength<3 /*the desired length*/, number /*the desired type of the array*/>;
//will result with [number, number, number]
6
colxi On

The Tuple approach :

This solution provides a strict FixedLengthArray (ak.a. SealedArray) type signature based in Tuples.

Syntax example :

// Array containing 3 strings
let foo : FixedLengthArray<[string, string, string]> 

This is the safest approach, considering it prevents accessing indexes out of the boundaries.

Implementation :

type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' | 'unshift' | number
type ArrayItems<T extends Array<any>> = T extends Array<infer TItems> ? TItems : never
type FixedLengthArray<T extends any[]> =
  Pick<T, Exclude<keyof T, ArrayLengthMutationKeys>>
  & { [Symbol.iterator]: () => IterableIterator< ArrayItems<T> > }

Tests :

var myFixedLengthArray: FixedLengthArray< [string, string, string]>

// Array declaration tests
myFixedLengthArray = [ 'a', 'b', 'c' ]  // ✅ OK
myFixedLengthArray = [ 'a', 'b', 123 ]  // ✅ TYPE ERROR
myFixedLengthArray = [ 'a' ]            // ✅ LENGTH ERROR
myFixedLengthArray = [ 'a', 'b' ]       // ✅ LENGTH ERROR

// Index assignment tests 
myFixedLengthArray[1] = 'foo'           // ✅ OK
myFixedLengthArray[1000] = 'foo'        // ✅ INVALID INDEX ERROR

// Methods that mutate array length
myFixedLengthArray.push('foo')          // ✅ MISSING METHOD ERROR
myFixedLengthArray.pop()                // ✅ MISSING METHOD ERROR

// Direct length manipulation
myFixedLengthArray.length = 123         // ✅ READ-ONLY ERROR

// Destructuring
var [ a ] = myFixedLengthArray          // ✅ OK
var [ a, b ] = myFixedLengthArray       // ✅ OK
var [ a, b, c ] = myFixedLengthArray    // ✅ OK
var [ a, b, c, d ] = myFixedLengthArray // ✅ INVALID INDEX ERROR

(*) This solution requires the noImplicitAny typescript configuration directive to be enabled in order to work (commonly recommended practice)


The Array(ish) approach :

This solution behaves as an augmentation of the Array type, accepting an additional second parameter(Array length). Is not as strict and safe as the Tuple based solution.

Syntax example :

let foo: FixedLengthArray<string, 3> 

Keep in mind that this approach will not prevent you from accessing an index out of the declared boundaries and set a value on it.

Implementation :

type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' |  'unshift'
type FixedLengthArray<T, L extends number, TObj = [T, ...Array<T>]> =
  Pick<TObj, Exclude<keyof TObj, ArrayLengthMutationKeys>>
  & {
    readonly length: L 
    [ I : number ] : T
    [Symbol.iterator]: () => IterableIterator<T>   
  }

Tests :

var myFixedLengthArray: FixedLengthArray<string,3>

// Array declaration tests
myFixedLengthArray = [ 'a', 'b', 'c' ]  // ✅ OK
myFixedLengthArray = [ 'a', 'b', 123 ]  // ✅ TYPE ERROR
myFixedLengthArray = [ 'a' ]            // ✅ LENGTH ERROR
myFixedLengthArray = [ 'a', 'b' ]       // ✅ LENGTH ERROR

// Index assignment tests 
myFixedLengthArray[1] = 'foo'           // ✅ OK
myFixedLengthArray[1000] = 'foo'        // ❌ SHOULD FAIL

// Methods that mutate array length
myFixedLengthArray.push('foo')          // ✅ MISSING METHOD ERROR
myFixedLengthArray.pop()                // ✅ MISSING METHOD ERROR

// Direct length manipulation
myFixedLengthArray.length = 123         // ✅ READ-ONLY ERROR

// Destructuring
var [ a ] = myFixedLengthArray          // ✅ OK
var [ a, b ] = myFixedLengthArray       // ✅ OK
var [ a, b, c ] = myFixedLengthArray    // ✅ OK
var [ a, b, c, d ] = myFixedLengthArray // ❌ SHOULD FAIL
0
Rodrigo Ferretti On

May be useful to someone:

type isZero<N extends number> = N extends 0 ? true : false;
type isNegative<N extends number> = `${N}` extends `-${string}` ? true : false;
type isDecimal<N extends number> = `${N}` extends `${string}.${string}` ? true : false;

type _Vector<N extends number, T extends unknown[] = []> = isZero<N> extends true
    ? never
    : isNegative<N> extends true
    ? never
    : isDecimal<N> extends true
    ? never
    : T["length"] extends N
    ? T
    : _Vector<N, [...T, number]>;

export type Vector<N extends number> = _Vector<N>;

let vec1: Vector<1>;    // [number]
let vec2: Vector<2>;    // [number, number]
let vec3: Vector<3>;    // [number, number, number]

let err1: Vector<2.5>;  // never
let err2: Vector<0>;    // never;
let err3: Vector<-1>;   // never

ChatGPT helped me with the solution, although I have to make some adjustments.