Should object literals with surplus properties be assignable to subtypes?

Topics: General, Language Specification
Feb 1, 2013 at 3:55 PM
When an object literal is used to specify the type of a variable or parameter, TypeScript permits any object containing a superset of the requisite properties to be passed in its stead.

For example:
var foo : { id: number; };

foo = { id:10, name:"foo"}; // Okay
This creates a subtle problem in the real world. Consider the following:
// Iteration One
class Animate{
    fadeOut(options: {opacity:number; duration: number;}){}
}

// Somewhere else in code
var animate = new Animate();
animate.fadeOut({ opacity: 0.7, duration: 200}); // Great! everything works
Sometime later:
// Iteration Two
class Animate{
       setDuration(duration:number){}
    fadeOut(options: {opacity:number;}){}
}
The author makes this change, compiles all projects in order to discover any errors, and takes the rest of the day off because there were no compilation errors.

But
// Somewhere else in code
var animate = new Animate();
animate.fadeOut({ opacity: 0.7, duration: 200}); // WTF? Why isn't this working?
Clearly this behaviour of TypeScript appears to have been put in place in order to permit cases such as the following:
class RandomOptions{
    width = 10;
    height = 20;
    colour = "blue";
    padding = 20;
    opacity = 0.5;
}

var animate = new Animate();
animate.fadeOut(new RandomOptions()); // Okay
I am not sure I agree that this is right. The last example is not good code. It's difficult to comprehend because we are not sure which of the options are being manipulated in the "fadeOut".

It's far better to require the caller to do the following:
var animate = new Animate();
var options = new RandomOptions();
animate.fadeOut({ opacity: options.opacity});
Are we encouraging a bad practice here?

Noel

(NB: This discussion is not about strongly-typed classes, which work as expected)
Coordinator
Feb 1, 2013 at 5:00 PM
This is a good opportunity for lint-type tools.

It's really hard to imagine a change to the type system that would preserve sane structural typing, but still catch this error. Imagine this code:
function printName(namedObject: { name: string; }) {
    console.log(namedObject.name);
}

function printWeight(weighableObject: { weight: number; }) {
    console.log(weighableObject.weight.toString());
}

var me = { name: 'Bob', weight: 150 };
printName(me); // should work, obviously
printWeight(me); // same
printName({ name: 'Bob', weight: 150 }); // ... shouldn't work??
The principle of least surprise needs to be in effect here - passing 'me' in place of the object literal that initialized it should have the exact same behavior.

What do you think about this example?
Feb 1, 2013 at 7:29 PM
@ryancavenaugh,

The point is the line printName({ name: 'Bob', weight: 150 }); is not very good code, because it isn't clear of the purpose of the "weight" property in a function named "printName".

So, what I suggest is the following for your example:
var me = { name: 'Bob', weight: 150 };
printName(me); // Shouldn't work
printWeight(me); // Shouldn't work
printName({ name: 'Bob', weight: 150 }); // Shouldn't work
printName({ name: 'Bob'}); // Should work
A better way to write this code is
// Alternative 1
function printPerson(person: {name: string; weight: number;}){}
var me = { name: 'Bob', weight: 150 };
printPerson(me); // Should work
Or, if the author wishes to support legacy code they could do the following:
// Alternative 2
interface INamed {
    name: string;
}
interface IWeighable {
    weight: number;
}
function printName(namedObject: INamed) {
    console.log(namedObject.name);
}
function printWeight(weighableObject: IWeighable) {
    console.log(weighableObject.weight.toString());
}
var me  = { name: 'Bob', weight: 150 };
printName(me); // Should work
printWeight(me); // Should work
printName({ name: 'Bob', weight: 150 }); // Should work
This alternative, while again not being "good code", is acceptable IMO because we are familiar with the notion of objects implementing multiple interfaces to describe behaviour.

Interfaces should be the only exception to this rule.

I hope that makes sense.
Feb 1, 2013 at 9:03 PM
ryancavanaugh wrote:
This is a good opportunity for lint-type tools.

It's really hard to imagine a change to the type system that would preserve sane structural typing, but still catch this error. Imagine this code:
If a proper type system can't handle it, what chance do linters have? (*)

I'm not sure how well this integrates with TS, but in statically typed functional languages this topic has received a lot of attention.
The usual approach to typing "extensible records" is to introduce some form of row type (giving names and types
to those properties that have not been listed explicitly), and to be explicit about whether a given record (parameter)
is meant to be extensible (there can be fields other than those listed explicitly) or not.

Inventing a random notation for extensible TS objects by borrowing array spread syntax, one would type
function f_extensible( { name : string; ... } ) { return name }   // parameter has at least a name property
function f_nonextensible( { name : string; } ) { return name }  // parameter has exactly a name property
f_extensible( { name: "joe"; height: 200 } ); // returns "joe"
f_nonextensible( { name: "input_field"; comment: "please enter data" } ); // type error, extraneous property
Those type systems even allow for polymorphism in rows
// same unknown properties in and out, at least name
function update( { name: string; ...rest } ) { return { name: name.toUpper(); ...rest } }
One notational difficulty is that JS and TS objects are extensible by default, until Object.preventExtensions is called on them.
So {} would be extensible and one would need a notation for non-extensible objects types.

Additional semantic difficulties come from Object.preventExtensions working by side-effect and not affecting the
prototype chain. So one would probably need to decouple structural typing with extensible and non-extensible
object types from use of Object.preventExtensions.

(*) a linter with access to TypeScript type information would be a useful project..
Feb 1, 2013 at 9:56 PM
Making a separate lint tool would require rebuilding some of the infrastructure the TypeScript compiler already has in place, no? Maybe the compiler itself should have a "lint" mode of compilation, where it presents such issues as warnings/messages.
Feb 2, 2013 at 8:37 AM
MgSam wrote:
Making a separate lint tool would require rebuilding some of the infrastructure the TypeScript compiler already has in place, no? Maybe the compiler itself should have a "lint" mode of compilation, where it presents such issues as warnings/messages.
The TypeScript project exposes most of that infrastructure as typeScriptServices.ts (would be good if that could be used as an external module, though).
So you could reuse that, just as TS IDE plugins do, and issue warnings instead of JS code. Linting needs tend to be highly personal/project-specific, and
with all that configurability and building up a pool of useful rules, it might well a separate project (just as JSHint is far from done when you have a JS parser).
Feb 3, 2013 at 1:09 PM
I like this notation:
function f_extensible( { name : string; ... } ) { return name }   // parameter has at least a name property
function f_nonextensible( { name : string; } ) { return name }  // parameter has exactly a name property
because the default usage favours non-extensible objects, which (I'm guessing) accounts for the larger proportion of all code that is written.

Although instead of having a new notation for extensible objects one can always declare the parameter as an interface - a bit more work but for the corner case.

I don't think the empty object "{}" behaves as an extensible object in TypeScript, which further strengthens the case for making object-literal typing non-extensible:
var foo: {} = {};
foo.name = "Bob"; // Error: The property 'name' does not exist...

// But
function bar(arg: {}){
}

var baz = {baz:"baz"};
bar(baz); // Eh?! Why is this compiling?
Feb 4, 2013 at 2:24 PM
@nablog I like that notation for anonymous objects.

I think this class of problem would also be helped by adding a sealed/final keyword for interfaces/classes.