2

Since TypeScript 5.9 we have (roughly speaking) Uint8Array = Uint8Array<ArrayBufferLike> = Uint8Array<ArrayBuffer | SharedArrayBuffer>. Now I am looking for a clean and idiomatic way to narrow down an input Uint8Array<ArrayBufferLike> to Uint8Array<ArrayBuffer> without unsafe casts.

Use case is the following function in which I would like to get rid of the extra new Uint8Array(buffer):

/**
 * Safely converts a Uint8Array<T> aka. Uint8Array into a
 * Uint8Array<ArrayBuffer>, often without a copy at runtime.
 *
 * @see https://github.com/paulmillr/scure-base/issues/53
 */
export function fixUint8Array<T extends ArrayBufferLike>(source: Uint8Array<T>): Uint8Array<ArrayBuffer> {
  const buffer = source.buffer;
  if (buffer instanceof ArrayBuffer) {
    // 🚨 source is still of type Uint8Array<T> in here but we know it is Uint8Array<ArrayBuffer>
    return new Uint8Array(buffer, source.byteOffset, source.length); // new instance just to make TS happy without unsafe cast
  }

  const copy = new ArrayBuffer(buffer.byteLength);
  new Uint8Array(copy).set(new Uint8Array(buffer));
  return new Uint8Array(copy);
}
10
  • You can always cast anything to anything in TypeScript using as unknown as T or as any as T, though your linter might complain. Commented Oct 29 at 14:04
  • 1
    @Bbrk24 OP mentions "without unsafe casts" in the question Commented Oct 29 at 14:35
  • "I would like to get rid of the extra new Uint8Array(buffer)" ...why? Are you calling this often enough to experience performance issues from doing so? Is it causing some object equality comparison you rely on to fail? It's not like it copies the whole underlying buffer when you do that... Commented Oct 29 at 14:41
  • 1
    No super strong reason. It's just a matter of elegance and safe code. The thinking here is: I already have a correct input type and I know it, why should I create a copy of the view? Just because of the type system. Commented Oct 29 at 14:45
  • 1
    Fair enough. I did upvote fwiw Commented Oct 29 at 14:50

2 Answers 2

3

TypeScript currently has no mechanism to narrow the type of an object by checking the type of one of its properties (except if the object is a discriminated union and the property being checked is the discriminant). There's a longstanding open feature request for this at microsoft/TypeScript#42384, but for now it's not part of the language. You can write a user-defined type guard function to simulate this behavior for some use cases, but there doesn't seem to be a one-size-fits-all solution.

In the case where the object is of a (non-discriminated) union type and you want to check one of its (non-discriminant) properties, one such user-defined type guard function looks like

function hasPropOfType<T, K extends keyof T, V extends T[K]>(
    obj: T, key: K, guard: (v: T[K]) => v is V): obj is Extract<T, Record<K, V>> {
    return guard(obj[key]);
}

This function, like all user-defined type guard functions, is only as safe as the implementation is correct, and TypeScript cannot and does not try to validate that for you. If you rewrote return Math.random() < 0.5 the code would still compile the same. The point is not that user-defined type guard functions guarantee safety automatically; the point is that the unsafe operation is confined to the implementation of one function, which you can then call multiple times in multiple places and it acts as if the missing feature were really there. It's a user-defined type guard, meaning you can write it yourself instead of waiting for a language feature request to be implemented.

If the requirement is that the language must already have the feature that lets you check source.buffer and have it automatically narrow source, then this is currently impossible. But presumably one wants a way forward despite that which is at least somewhat less ad-hoc than a type assertion.

For what remains I'll assume that calling hasPropOfType() is a viable path forward (if not, one can just stop reading here).


You could use hasPropOfType() if you rewrite your function to accept a union of Uint8Array<⋯> types instead of a generic type:

function fixUint8Array(
    source: Uint8Array<ArrayBuffer> | Uint8Array<SharedArrayBuffer> | Uint8Array
): Uint8Array<ArrayBuffer> {
    if (hasPropOfType(source, "buffer", x => x instanceof ArrayBuffer)) {
        return source
    }
    const buffer = source.buffer;
    const copy = new ArrayBuffer(buffer.byteLength);
    new Uint8Array(copy).set(new Uint8Array(buffer));
    return new Uint8Array(copy);
}

Here you're checking the buffer property of source using the callback guard x => x instanceof ArrayBuffer, which is inferred as a type predicate returning function automatically for you. So inside the true block of the if statement you get that source has now been narrowed to Uint8Array<ArrayBuffer>. Inside the false block it has not been narrowed (well, it's been narrowed to Uint8Array<SharedArrayBuffer> | Uint8Array<ArrayBufferLike>) and you can copy the buffer as before.

Note that if you wanted to keep it generic you'd have to write an even more special-cased type guard function, because TypeScript does not and has never re-constrained generic type arguments based on type guard checks. Like, the following doesn't work and there's no way for it to work:

function hmm<U>(arr: U[]) {
    if (typeof arr[0] === "string") {
        arr.map(x => x.toUpperCase()); // error!
    }
    if (hasPropOfType(arr, 0, x => typeof x === "string")) {
        arr.map(x => x.toUpperCase()); // error!
    }
}

The ability to check a generic-typed value and have TypeScript re-constrain the generic type argument (e.g., reconstrain T from T extends ArrayBufferLike to T extends ArrayBuffer upon checking source.buffer, or constrain U to U extends string upon checking arr[0]) is another missing feature. The closest I can think of for that one is microsoft/TypeScript#27808, but that one would only help if the constraint were a list of possibilities and not a union. So instead of Uint8Array<T> where T extends ArrayBufferLike, you'd need Uint8Array<T> where T oneof (ArrayBuffer, SharedArrayBuffer) (made-up syntax), so you'd need to refactor your code anyway.

This is something you can't even write a user-defined type guard function to fix in general, since you can't abstract over generics that way. That would require higher-kinded types as discussed in microsoft/TypeScript#1213. Which means that the only way to do it would be to pick a particular generic thing, like, say Uint8Array itself. Leading to the type guard function like

function hasBufferOfType<T extends ArrayBufferLike, U extends T>(
    source: Uint8Array<T>, guard: (buffer: T) => buffer is U): source is Uint8Array<U> {
    return guard(source.buffer)
}

and the implementation of fixUint8Array like

function fixUint8Array<T extends ArrayBufferLike>(
    source: Uint8Array<T>
): Uint8Array<ArrayBuffer> {
    if (hasBufferOfType(source, x => x instanceof ArrayBuffer)) {
        return source
    }
    const buffer = source.buffer;
    const copy = new ArrayBuffer(buffer.byteLength);
    new Uint8Array(copy).set(new Uint8Array(buffer));
    return new Uint8Array(copy);
}

This works nicely, but without higher-kinded types it's fairly limited in applicability, and there's less of a chance you'd need to have a separate function for it, especially if you only ever call it inside fixUint8Array. You might then decide to just use a type assertion and comment the function like

function fixUint8Array3<T extends ArrayBufferLike>(
    source: Uint8Array<T>
): Uint8Array<ArrayBuffer> {
    const buffer = source.buffer;
    if (buffer instanceof ArrayBuffer) {        
        return source as Uint8Array<ArrayBuffer>; // TS can't figure this out itself
    }
    const copy = new ArrayBuffer(buffer.byteLength);
    new Uint8Array(copy).set(new Uint8Array(buffer));
    return new Uint8Array(copy);
}

Which is against the requirement of the question here.


Playground link to code

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

1 Comment

That's amazing – thank you for the massive elaboration. I think I'll end up with source: Uint8Array<ArrayBuffer> | Uint8Array to represent the special and the generic case and then use either hasBufferOfType or hasPropOfType as shown above.
3

You can extract the instanceof check to a type predicate function:

function isUint8ArrayOfArrayBuffer(source: Uint8Array<ArrayBufferLike>): source is Uint8Array<ArrayBuffer> {
  return source.buffer instanceof ArrayBuffer;
}

export function fixUint8Array<T extends ArrayBufferLike>(source: Uint8Array<T>): Uint8Array<ArrayBuffer> {
  if (isUint8ArrayOfArrayBuffer(source)) {
    return source;
  }

  const copy = new ArrayBuffer(source.byteLength);
  new Uint8Array(copy).set(source);
  return new Uint8Array(copy);
}

7 Comments

That's really no different than casting.
The difference is that you can call isUint8ArrayOfArrayBuffer as many times as you want and you only need to manually verify the safety of its implementation once. User-defined type guard functions a preferred way to express narrowings TS can't do by itself. If you're only calling this function once in your whole code base, then yes, there's no benefit.
Yes I know, but OP specifically asked to do this without casts...
"narrowings TS can't do by itself" That's the point. I think TS should be doing that for us after the massive breaking change introduced in TS 5.9 and I am researching if and how TS can do that by itself.
Wait, is this about what TS "should" do, or what it actually does? The conventional approach is to refactor the type check into a type predicate function, so that the user-verified narrowing happens there. If by avoiding "casting", one means avoiding any approach where the user takes responsibility for type safety because TS cannot, then this is simply impossible, and the only answer one can give is that it is impossible. You need to do something like "casting" somewhere here. Whether or not you "should". TS has a track record of not doing everything everyone thinks it "should". 🤷‍♂️
Well I am a user searching for a feature from TypeScript. Just because I can't find it does not mean it does not exist. I think it is reasonable it could be there somewhere. Hard for me to know it does not exist. Mybe "it's impossible" with some explanation is the correct answer.
I was thinking of this too. But at the end of the day this is also an unsafe cast which relies on my ability to implement isUint8ArrayOfArrayBuffer correctly. Chances are it is correct. But it may not or may change over time.

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.