An "unlimited" data model I need to store an unknown amount  of fields. Some are numbers (like IDs, zip codes and times), some are short strings (like names and states), and some are longer strings (such as descriptions). All fields are related to an ID.

I suppose I'd go with some kind of normalization, but what's the most optimal way? Have a table for each data type?

Thank you in advance!
The answer depends quite a bit on what you want to do.  One option is EAV as one of the notes pointed out.  Another option is what I call EAV-lite which is to have stored procedures for adding columns to an additional "extended attributes" table.  This gives you something of a fixed schema with extended attributes.  If you need to add something else you can do it.  Of course you need EAV-like catalogs to tell you which attributes to search on, which is why I call it EAV-lite.

What we did in LedgerSMB was to create two additional tables for this catalog info: custom_table_catalog and custom_field_catalog for tracking these extensions.  Then the extends_* tables hold these extended attributes horizontally.

If you were on Postgres you could use hstore for these extended attributes too.  In 9.1 you could even use JSON.  Of course care needs to be taken in breaking the standard normalized approach here.  I would recommend EAV-lite more likely.

Edit: adding sample code.  Note that this works on PostgreSQL but on MySQL you will need to rewrite the functions as stored procs and possibly some other things.

CREATE TABLE custom_table_catalog (
table_id SERIAL PRIMARY KEY,
extends TEXT,
table_name TEXT
);

CREATE TABLE custom_field_catalog (
field_id SERIAL PRIMARY KEY,
table_id INT REFERENCES custom_table_catalog,
field_name TEXT
);

CREATE OR REPLACE FUNCTION add_custom_field (table_name VARCHAR, new_field_name VARCHAR, field_datatype VARCHAR) 
RETURNS BOOL AS
'
BEGIN
        perform TABLE_ID FROM custom_table_catalog
                WHERE extends = table_name;
        IF NOT FOUND THEN
                BEGIN
                        INSERT INTO custom_table_catalog (extends)
                                VALUES (table_name);
                        EXECUTE ''CREATE TABLE '' ||
                               quote_ident(''custom_'' ||table_name) ||
                                '' (row_id INT PRIMARY KEY)'';
                EXCEPTION WHEN duplicate_table THEN
                        -- do nothing
                END;
        END IF;
        INSERT INTO custom_field_catalog (field_name, table_id)
        values (new_field_name, (SELECT table_id
                                        FROM custom_table_catalog
                WHERE extends = table_name));
        EXECUTE ''ALTER TABLE ''|| quote_ident(''custom_''||table_name) ||
                '' ADD COLUMN '' || quote_ident(new_field_name) || '' '' ||
                  quote_ident(field_datatype);
        RETURN TRUE;
END;
' LANGUAGE PLPGSQL;
-- end function

CREATE OR REPLACE FUNCTION drop_custom_field (VARCHAR, VARCHAR) 
RETURNS BOOL AS
'
DECLARE
table_name ALIAS FOR $1;
custom_field_name ALIAS FOR $2;
BEGIN
        DELETE FROM custom_field_catalog
        WHERE field_name = custom_field_name AND
                table_id = (SELECT table_id FROM custom_table_catalog
                        WHERE extends = table_name);
        EXECUTE ''ALTER TABLE '' || quote_ident(''custom_'' || table_name) ||
                '' DROP COLUMN '' || quote_ident(custom_field_name);
        RETURN TRUE;
END;
' LANGUAGE PLPGSQL;


I don't know if MySQL allows for alter table statements to be done dynamically, so worst case scenario you might have to maintain these functions in the app layer.

The application then selects the contents of custom_table_catalog and custom_field_catalog and caches these in memory.  When an item is saved or retrieved, these are then used to create SQL CRUD functions.  Currently they are used for orders, invoices, goods, and services.