Typescript: User-defined type assertions

Created on 12 Aug 2017  ·  17Comments  ·  Source: microsoft/TypeScript

Proposal: support user-defined type assertions (keyword as) in addition to user-defined type guards (keyword is).

A user-defined type assertion is a function with a return type expressed as in the following example (continuing the handbook example in "Advanced Types").

function assertIsFish(pet: Fish | Bird): pet as Fish {
    if (!isFish(pet))
        throw new Error("birds are not supported");
}

assertIsFish(pet);

pet.swim();

:pet as fish means that, if the function assertIsFish returns without throwing, pet is automatically asserted to be of type Fish.

Use-cases:

  1. Validators: user-defined type assertions make it possible to encapsulate in functions the type narrowing that is currently available through control flow analysis. They also allow types that better describe the behavior of many existing libraries, including: node asserts, data validators that throw detailed errors, many test frameworks.

  2. Polyfills: enables describing functions that modify objects by adding new properties.

addName<T>(obj: T): obj as T & { name: string } {
    obj.name = "Mike";
}

const obj = {};
addName(obj);

obj.name.indexOf("i");

Validator examples:

simple undefined checking

function notUndefined<T>(obj: T | undefined): obj as T {
    if (obj === undefined)
        throw new Error("invalid object");
}

const myObj: string | undefined = {} as any;

// [ts] Object is possibly 'undefined'.
myObj.indexOf("");

notUndefined(myObj);

// no errors
myObj.indexOf("");

joi-like validation currently requires implementing the schema description of an object for runtime checking and copying the schema to an interface to have static type checking while the static type could be inferred by the implementation of the schema

interface Schema<T> {
    validate(obj: any): obj is T;
}

function attemptValidation<T>(obj: any, schema: Schema<T>): obj as T {
    if (!schema.validate(obj))
        throw new Error("very detailed object validation error");
}

// complex schema implements T and T is inferred by the schema construction
// this is already possible
const complexSchema = Joi.object({
    myField: Joi.date().iso()
});

attemptValidation(objFromTheRealWorld, complexSchema);

const day = objFromTheReadWorld.myField.getDay();
Awaiting More Feedback Suggestion

All 17 comments

I like the idea. I've needed something like this before, and the way I've done it is by immediately throwing if the relevant type guard returns false. So you can have the somewhat cumbersome:

function unsafeNarrow<T>(x: any): x is T {
  return true;
}
const obj = {height: 42};  // obj is {height: number}
if (!unsafeNarrow<{ name: string }>(obj)) throw null;
// obj is {height: number} & {name: string} from here on
obj.name = 'mike';  // no error

It would be nice to have something less ugly. Not sure if the x as T syntax is the way to go? Maybe we can continue to use x is T but the control flow analysis can tell that the returned value is always true and therefore any call to the type guard does automatic narrowing? So something like true & (x is T), if it compiled, would be a possibility.

@jcalz I agree, I'm open to any syntax, that was just a suggestion as a starting point.

Another really annoying thing is in tests, where you first assert that an object is truthy and then you have to explicitly check for it with an if or a type assertion before you can assert on values of the object.

// obj comes from some test invocation
assert(obj);
// we already know that obj is not undefined but
if (obj) {
    assert.equal(obj.field, "foo");
}

I was just looking for something like this as well. I agree with @jcalz that it seems like the existing syntax should continue to work and it's just the compiler code analysis can take into account that the failure in the function means the code throws and exits and a non-failure confirms the assertion.

@TazmanianD I agree, we don't necessarily need new syntax, it was just nice to know by just looking at the function signature that I don't have to check for a return value.

I was just looking for something like this as well. I don't think you can use the same x is T return value syntax because you won't always be able to do control flow analysis on the called function, such as if it's referencing a function declaration.

I'm also not sure that the original return value x as T syntax works either since it doesn't really have any thing to do with the return value of the function, just that the function actually returned. It's more of a postcondition. Having a syntax that goes in the return value slot would prohibit having an actual return value from the function. For example:

// where do I write an explicit return type?
function initializeAndGetId<T>(obj: T): obj as T&{id: number} { 
  (obj as any).id = generateId();
  return obj.id;
}

const value = {};
const id = initializeAndGetId(value);
// this should compile since value should now be assumed to have the type {id: number}
assert(id === value.id);

Unfortunately, I don't really have any better suggestions.

There are a number of ES built-in functions that would greatly benefit from this like Number.isFinite(). Currently the following will not work:

function method(param: any) {
    const myNumber: number = Number.isFinite(param) ? param : 5
}

Instead, you must do something like:

function method(param: any) {
    const myNumber: number = (typeof param === 'number' && Number.isFinite(param)) ? param : 5
}

@MicahZoltu In that case it's because param is already defined as number in the type definitions.
You could achieve the same results with the existing syntax if the function was defined as (param: any) => param is number.

Adding param is number was my hope, but apparently it doesn't actually work in this case as described here: https://github.com/Microsoft/TypeScript/pull/24436#issuecomment-393911190

@MicahZoltu that makes sense, I didn't think about the side effects.

I'm also running into a similar situation to @maghis in tests as #17760 (comment). I'd like for assert.isDefined(param) to be able to be typed the same as an if that throws an error.

let callbackParam: string | undefined;
doesThisCallTheCallback((p) => callbackParam = p);

if (callbackParam == null) { // Want this to be in assert.isDefined()
  throw new Error("TypeScript knows callbackParam is string if this hasn't thrown yet");
}
assert.isDefined(callbackParam); // throws if undefined
assert.equals(callbackParam.split(' ').length, 5); // Should know callbackParam is a string here

@aciccarello FYI that behavior is tracked at #10421 / #8655

@RyanCavanaugh Issue https://github.com/Microsoft/TypeScript/issues/8655 was closed without good explanation. Many questions in the thread are still without answers. Suggested throw expressions doesn't solve all cases, e.g. process.exit() in Node.js.

Testing with chai is another obvious place where this would be nice:

import { expect } from 'chai';

it('should do the thing', function() {
  const result = doTheThing();
  expect(result).to.exist; 
  expect(result!.foo).to.equal(7); // I don't want to type this !.  :P
});

I feel like there should be some sort of "condtional type" syntax that could be used here. Something like:

function assertNotUndefined(x: string | undefined) : x is undefined ? true : never {
    if(x === undefined) {
        throw new Error('undefined');
    }
    return true;
}

In order to meet our use case, user-defined type assertions would have to be intersectable with normal return values. In the below, I use this syntax instead of as T: Ensures<T>.

For example, suppose that you have a UI framework where children only have .layout property if they have been added to a parent.

It would be really helpful if we could plug in to the same sort of flow analysis used by type guards.

Example of desired behavior using Ensures:

// adding a button to a container gives it a 'layout' property
const button = new Button();
button.layout; // error because we don't know if it is added to a container yet
if (shouldAdd) {
    container.addChild(button);
    button.layout; // ok because of the `Ensures` type (defined below)
}
else {
    button.layout; // error because control flow didn't hit anything that ensures `button.layout` defined
}

// definitions
class Component { /* ... */ }
class Container extends Component {
    children: Component[] = [];
    addChild<T extends Component>(child: T): this & Ensures<T is T & { layout: Layout }> {
        this.children.push(child)
    }
}

This should probably be closed now that #32695 has been merged (although that PR still doesn't cover the request in the comment immediately above, to be able to return a normal value and assert with the same function).

I think so, asserts covers pretty much all the listed use cases.
Anyone against me closing?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jbondc picture jbondc  ·  3Comments

CyrusNajmabadi picture CyrusNajmabadi  ·  3Comments

fwanicka picture fwanicka  ·  3Comments

blendsdk picture blendsdk  ·  3Comments

DanielRosenwasser picture DanielRosenwasser  ·  3Comments