Congratulations! You have just circumnavigated the programming language / type system globe, arriving on the other side of the world from whence you departed. You have just landed on the border of dynamic language / prototype-based object land!
do not have classes, strictly speaking. You can do things that look like class-based, object-oriented programming with inheritance, but the rules are greatly relaxed compared to more sharply-defined, class-based languages like Java and C#.
The big tradeoff for this dynamism is performance. Forget how strongly or weakly typed the language is, or how well it can be compiled down to machine code. Dynamic objects must be represented as flexible maps/dictionaries, rather than simple structs. This adds overhead to every object access. Some programs go to great lengths to reduce this overhead (e.g. with phantom kwarg assignment and slot-based classes in Python), but the extra overhead is usually just par for the course and the price of admission.
Getting back to your design, you're grafting the ability to have dynamic properties onto a subset of your classes. A
Product can have variable attributes; presumably an
Invoice or an
Order would and could not. It's not a bad way to go. It gives you the flexibility to have variation where you need it, while remaining in a strict, disciplined language and type system. On the down side, you are responsible for managing those flexible properties, and you'll probably have to do so through mechanisms that look slightly different from more native attributes.
p.prop('tensile_strength') rather than
p.tensile_strength, for instance, and
p.set_prop('tensile_strength', 104.4) rather than
p.tensile_strength = 104.4. But I've worked with and built many programs in Pascal, Ada, C, Java and even dynamic languages that used exactly such getter-setter access for non-standard attribute types; the approach is clearly workable.
By the by, this tension between static types and a highly varied world is extremely common. An analogous problem is often seen when designing database schema, especially for relational and pre-relational data stores. Sometimes it's dealt with by creating "super-rows" that contain enough flexibility to contain or define the union of all imagined variations, then stuffing any data that comes along into those fields. The WordPress
wp_posts table, for example, has fields like
post_date_gmt that are only interesting under some circumstances, and that in practice often go blank. Another approach is a very spare, normalized table like
wp_options, much like your
Property class. While it requires more explicit management, items in it are rarely blank. Object-oriented and document databases (e.g. MongoDB) often have an easier time dealing with changing options, because they can create and set attributes pretty much at will.